diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallActivity.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallActivity.java new file mode 100644 index 00000000..62d88e5f --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallActivity.java @@ -0,0 +1,314 @@ +package com.example.livestreaming.call; + +import android.media.AudioManager; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.view.WindowManager; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; + +import com.bumptech.glide.Glide; +import com.example.livestreaming.R; + +/** + * 通话界面 + */ +public class CallActivity extends AppCompatActivity implements CallManager.CallStateListener { + + private ImageView ivBackgroundAvatar; + private ImageView ivAvatar; + private TextView tvUserName; + private TextView tvCallStatus; + private TextView tvCallDuration; + private TextView tvCallType; + private ImageButton btnMinimize; + private ImageButton btnMute; + private ImageButton btnSpeaker; + private ImageButton btnVideo; + private ImageButton btnHangup; + private ImageButton btnSwitchCamera; + private LinearLayout layoutCallControls; + private LinearLayout layoutVideoToggle; + + private CallManager callManager; + private AudioManager audioManager; + private Handler handler; + + private String callId; + private String callType; + private boolean isCaller; + private int otherUserId; + private String otherUserName; + private String otherUserAvatar; + + private boolean isMuted = false; + private boolean isSpeakerOn = false; + private boolean isVideoEnabled = true; + private boolean isConnected = false; + private long callStartTime = 0; + + private Runnable durationRunnable = new Runnable() { + @Override + public void run() { + if (isConnected && callStartTime > 0) { + long duration = (System.currentTimeMillis() - callStartTime) / 1000; + int minutes = (int) (duration / 60); + int seconds = (int) (duration % 60); + tvCallDuration.setText(String.format("%02d:%02d", minutes, seconds)); + handler.postDelayed(this, 1000); + } + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // 保持屏幕常亮,显示在锁屏上方 + getWindow().addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON | + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED | + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ); + + setContentView(R.layout.activity_call); + + initViews(); + initData(); + initCallManager(); + setupListeners(); + updateUI(); + } + + private void initViews() { + ivBackgroundAvatar = findViewById(R.id.ivBackgroundAvatar); + ivAvatar = findViewById(R.id.ivAvatar); + tvUserName = findViewById(R.id.tvUserName); + tvCallStatus = findViewById(R.id.tvCallStatus); + tvCallDuration = findViewById(R.id.tvCallDuration); + tvCallType = findViewById(R.id.tvCallType); + btnMinimize = findViewById(R.id.btnMinimize); + btnMute = findViewById(R.id.btnMute); + btnSpeaker = findViewById(R.id.btnSpeaker); + btnVideo = findViewById(R.id.btnVideo); + btnHangup = findViewById(R.id.btnHangup); + btnSwitchCamera = findViewById(R.id.btnSwitchCamera); + layoutCallControls = findViewById(R.id.layoutCallControls); + layoutVideoToggle = findViewById(R.id.layoutVideoToggle); + + handler = new Handler(Looper.getMainLooper()); + audioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + } + + private void initData() { + callId = getIntent().getStringExtra("callId"); + callType = getIntent().getStringExtra("callType"); + isCaller = getIntent().getBooleanExtra("isCaller", true); + otherUserId = getIntent().getIntExtra("otherUserId", 0); + otherUserName = getIntent().getStringExtra("otherUserName"); + otherUserAvatar = getIntent().getStringExtra("otherUserAvatar"); + + // 如果是被叫方接听,直接进入通话状态 + boolean alreadyConnected = getIntent().getBooleanExtra("isConnected", false); + if (alreadyConnected) { + android.util.Log.d("CallActivity", "被叫方接听,直接进入通话状态"); + isConnected = true; + callStartTime = System.currentTimeMillis(); + } + } + + private void initCallManager() { + callManager = CallManager.getInstance(this); + callManager.setStateListener(this); + + // 确保WebSocket已连接(主叫方需要接收接听/拒绝通知) + String userId = com.example.livestreaming.net.AuthStore.getUserId(this); + if (userId != null && !userId.isEmpty()) { + try { + int uid = (int) Double.parseDouble(userId); + if (uid > 0) { + android.util.Log.d("CallActivity", "确保WebSocket连接,userId: " + uid); + callManager.connect(uid); + } + } catch (NumberFormatException e) { + android.util.Log.e("CallActivity", "解析用户ID失败", e); + } + } + } + + private void setupListeners() { + btnMinimize.setOnClickListener(v -> { + // 最小化通话(后台运行) + moveTaskToBack(true); + }); + + btnMute.setOnClickListener(v -> toggleMute()); + btnSpeaker.setOnClickListener(v -> toggleSpeaker()); + btnVideo.setOnClickListener(v -> toggleVideo()); + btnSwitchCamera.setOnClickListener(v -> switchCamera()); + + btnHangup.setOnClickListener(v -> { + if (isConnected) { + callManager.endCall(callId, "user_hangup"); + } else if (isCaller) { + callManager.cancelCall(callId); + } else { + callManager.rejectCall(callId); + } + finish(); + }); + } + + private void updateUI() { + // 设置用户信息 + tvUserName.setText(otherUserName != null ? otherUserName : "用户" + otherUserId); + + if (otherUserAvatar != null && !otherUserAvatar.isEmpty()) { + Glide.with(this).load(otherUserAvatar).into(ivAvatar); + Glide.with(this).load(otherUserAvatar).into(ivBackgroundAvatar); + } + + // 设置通话类型 + boolean isVideo = "video".equals(callType); + tvCallType.setText(isVideo ? "视频通话" : "语音通话"); + layoutVideoToggle.setVisibility(isVideo ? View.VISIBLE : View.GONE); + btnSwitchCamera.setVisibility(isVideo ? View.VISIBLE : View.GONE); + + // 根据连接状态设置界面 + if (isConnected) { + // 已接通,显示通话中界面 + tvCallStatus.setVisibility(View.GONE); + tvCallDuration.setVisibility(View.VISIBLE); + layoutCallControls.setVisibility(View.VISIBLE); + handler.post(durationRunnable); + android.util.Log.d("CallActivity", "updateUI: 已接通状态,显示计时器"); + } else { + // 未接通,显示等待状态 + if (isCaller) { + tvCallStatus.setText("正在呼叫..."); + } else { + tvCallStatus.setText("正在连接..."); + } + } + } + + private void toggleMute() { + isMuted = !isMuted; + btnMute.setImageResource(isMuted ? R.drawable.ic_mic_off : R.drawable.ic_mic); + btnMute.setBackgroundResource(isMuted ? R.drawable.bg_call_button_active : R.drawable.bg_call_button); + // TODO: 实际静音控制 + Toast.makeText(this, isMuted ? "已静音" : "已取消静音", Toast.LENGTH_SHORT).show(); + } + + private void toggleSpeaker() { + isSpeakerOn = !isSpeakerOn; + audioManager.setSpeakerphoneOn(isSpeakerOn); + btnSpeaker.setBackgroundResource(isSpeakerOn ? R.drawable.bg_call_button_active : R.drawable.bg_call_button); + Toast.makeText(this, isSpeakerOn ? "已开启免提" : "已关闭免提", Toast.LENGTH_SHORT).show(); + } + + private void toggleVideo() { + isVideoEnabled = !isVideoEnabled; + btnVideo.setBackgroundResource(isVideoEnabled ? R.drawable.bg_call_button : R.drawable.bg_call_button_active); + // TODO: 实际视频控制 + Toast.makeText(this, isVideoEnabled ? "已开启摄像头" : "已关闭摄像头", Toast.LENGTH_SHORT).show(); + } + + private void switchCamera() { + // TODO: 切换前后摄像头 + Toast.makeText(this, "切换摄像头", Toast.LENGTH_SHORT).show(); + } + + private void onCallConnected() { + android.util.Log.d("CallActivity", "onCallConnected() 开始执行"); + isConnected = true; + callStartTime = System.currentTimeMillis(); + + tvCallStatus.setVisibility(View.GONE); + tvCallDuration.setVisibility(View.VISIBLE); + layoutCallControls.setVisibility(View.VISIBLE); + + handler.post(durationRunnable); + android.util.Log.d("CallActivity", "onCallConnected() 执行完成,通话已连接"); + Toast.makeText(this, "通话已接通", Toast.LENGTH_SHORT).show(); + } + + // CallStateListener 实现 + @Override + public void onCallStateChanged(String state, String callId) { + // 状态变化处理 + } + + @Override + public void onIncomingCall(String callId, int callerId, String callerName, String callerAvatar, String callType) { + // 来电处理(在IncomingCallActivity中处理) + } + + @Override + public void onCallConnected(String callId) { + android.util.Log.d("CallActivity", "========== onCallConnected 被调用 =========="); + android.util.Log.d("CallActivity", "callId: " + callId); + android.util.Log.d("CallActivity", "this.callId: " + this.callId); + runOnUiThread(() -> { + android.util.Log.d("CallActivity", "执行 onCallConnected UI更新"); + onCallConnected(); + }); + } + + @Override + public void onCallEnded(String callId, String reason) { + runOnUiThread(() -> { + String message; + switch (reason) { + case "rejected": + message = "对方已拒绝"; + break; + case "cancelled": + message = "通话已取消"; + break; + case "timeout": + message = "对方无应答"; + break; + case "busy": + message = "对方忙线中"; + break; + case "peer_disconnect": + message = "对方已断开"; + break; + default: + message = "通话已结束"; + } + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + finish(); + }); + } + + @Override + public void onError(String error) { + runOnUiThread(() -> { + Toast.makeText(this, "通话错误: " + error, Toast.LENGTH_SHORT).show(); + }); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + handler.removeCallbacks(durationRunnable); + if (callManager != null) { + callManager.setStateListener(null); + } + } + + @Override + public void onBackPressed() { + // 禁止返回键退出,需要点击挂断 + moveTaskToBack(true); + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallApiResponse.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallApiResponse.java new file mode 100644 index 00000000..51d47259 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallApiResponse.java @@ -0,0 +1,38 @@ +package com.example.livestreaming.call; + +/** + * 通话API响应包装类 + */ +public class CallApiResponse { + private int code; + private String message; + private T data; + + public boolean isSuccess() { + return code == 200; + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallApiService.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallApiService.java new file mode 100644 index 00000000..ba72570d --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallApiService.java @@ -0,0 +1,76 @@ +package com.example.livestreaming.call; + +import java.util.List; +import java.util.Map; + +import retrofit2.Call; +import retrofit2.http.*; + +/** + * 通话相关API接口 + */ +public interface CallApiService { + + /** + * 发起通话 + */ + @POST("api/front/call/initiate") + Call> initiateCall(@Body InitiateCallRequest request); + + /** + * 接听通话 + */ + @POST("api/front/call/accept/{callId}") + Call> acceptCall(@Path("callId") String callId); + + /** + * 拒绝通话 + */ + @POST("api/front/call/reject/{callId}") + Call> rejectCall(@Path("callId") String callId); + + /** + * 取消通话 + */ + @POST("api/front/call/cancel/{callId}") + Call> cancelCall(@Path("callId") String callId); + + /** + * 结束通话 + */ + @POST("api/front/call/end/{callId}") + Call> endCall(@Path("callId") String callId, @Query("endReason") String endReason); + + /** + * 获取通话记录 + */ + @GET("api/front/call/history") + Call> getCallHistory( + @Query("page") int page, + @Query("limit") int limit + ); + + /** + * 删除通话记录 + */ + @DELETE("api/front/call/record/{recordId}") + Call> deleteCallRecord(@Path("recordId") long recordId); + + /** + * 获取未接来电数量 + */ + @GET("api/front/call/missed/count") + Call> getMissedCallCount(); + + /** + * 获取通话状态 + */ + @GET("api/front/call/status") + Call>> getCallStatus(); + + /** + * 获取通话详情 + */ + @GET("api/front/call/detail/{callId}") + Call> getCallDetail(@Path("callId") String callId); +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryActivity.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryActivity.java new file mode 100644 index 00000000..61dcb6ae --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryActivity.java @@ -0,0 +1,216 @@ +package com.example.livestreaming.call; + +import android.os.Bundle; +import android.widget.ImageButton; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.example.livestreaming.R; +import com.example.livestreaming.net.ApiClient; +import com.example.livestreaming.net.AuthStore; + +import java.util.List; + +import okhttp3.OkHttpClient; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; + +/** + * 通话记录列表界面 + */ +public class CallHistoryActivity extends AppCompatActivity { + + private ImageButton btnBack; + private TextView tvMissedCount; + private SwipeRefreshLayout swipeRefresh; + private RecyclerView rvCallHistory; + + private CallHistoryAdapter adapter; + private CallApiService apiService; + private CallManager callManager; + + private int currentPage = 1; + private boolean isLoading = false; + private boolean hasMore = true; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_call_history); + + initViews(); + initApiService(); + initCallManager(); + setupListeners(); + loadData(); + loadMissedCount(); + } + + private void initViews() { + btnBack = findViewById(R.id.btnBack); + tvMissedCount = findViewById(R.id.tvMissedCount); + swipeRefresh = findViewById(R.id.swipeRefresh); + rvCallHistory = findViewById(R.id.rvCallHistory); + + adapter = new CallHistoryAdapter(); + rvCallHistory.setLayoutManager(new LinearLayoutManager(this)); + rvCallHistory.setAdapter(adapter); + } + + private void initApiService() { + String baseUrl = ApiClient.getCurrentBaseUrl(this); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(chain -> { + String token = AuthStore.getToken(CallHistoryActivity.this); + okhttp3.Request.Builder req = chain.request().newBuilder(); + if (token != null && !token.isEmpty()) { + req.header("Authori-zation", token); + req.header("Authorization", token); + } + return chain.proceed(req.build()); + }) + .build(); + + Retrofit retrofit = new Retrofit.Builder() + .baseUrl(baseUrl) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build(); + + apiService = retrofit.create(CallApiService.class); + } + + private void initCallManager() { + callManager = CallManager.getInstance(this); + } + + private void setupListeners() { + btnBack.setOnClickListener(v -> finish()); + + swipeRefresh.setOnRefreshListener(() -> { + currentPage = 1; + hasMore = true; + loadData(); + }); + + adapter.setOnItemClickListener(new CallHistoryAdapter.OnItemClickListener() { + @Override + public void onItemClick(CallRecordResponse record) { + // 点击查看详情或回拨 + showCallOptions(record); + } + + @Override + public void onCallClick(CallRecordResponse record) { + // 回拨 + makeCall(record); + } + }); + + // 加载更多 + rvCallHistory.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager(); + if (layoutManager != null) { + int totalItemCount = layoutManager.getItemCount(); + int lastVisibleItem = layoutManager.findLastVisibleItemPosition(); + if (!isLoading && hasMore && lastVisibleItem >= totalItemCount - 3) { + loadMore(); + } + } + } + }); + } + + private void loadData() { + isLoading = true; + apiService.getCallHistory(currentPage, 20).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, + Response> response) { + swipeRefresh.setRefreshing(false); + isLoading = false; + + if (response.isSuccessful() && response.body() != null && response.body().isSuccess()) { + CallHistoryResponse data = response.body().getData(); + if (data != null && data.getList() != null) { + if (currentPage == 1) { + adapter.setRecords(data.getList()); + } else { + adapter.addRecords(data.getList()); + } + hasMore = data.getList().size() >= 20; + } + } else { + Toast.makeText(CallHistoryActivity.this, "加载失败", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call> call, Throwable t) { + swipeRefresh.setRefreshing(false); + isLoading = false; + Toast.makeText(CallHistoryActivity.this, "网络错误", Toast.LENGTH_SHORT).show(); + } + }); + } + + private void loadMore() { + currentPage++; + loadData(); + } + + private void loadMissedCount() { + apiService.getMissedCallCount().enqueue(new Callback>() { + @Override + public void onResponse(Call> call, + Response> response) { + if (response.isSuccessful() && response.body() != null && response.body().isSuccess()) { + Integer count = response.body().getData(); + if (count != null && count > 0) { + tvMissedCount.setText(count + "未接"); + tvMissedCount.setVisibility(android.view.View.VISIBLE); + } + } + } + + @Override + public void onFailure(Call> call, Throwable t) { + // 忽略错误 + } + }); + } + + private void showCallOptions(CallRecordResponse record) { + // 可以显示底部弹窗,提供回拨、删除等选项 + // 这里简化处理,直接回拨 + makeCall(record); + } + + private void makeCall(CallRecordResponse record) { + String callType = record.getCallType(); + int otherUserId = record.getOtherUserId(); + + callManager.initiateCall(otherUserId, callType, new CallManager.CallCallback() { + @Override + public void onSuccess(InitiateCallResponse response) { + // 通话界面会自动启动 + } + + @Override + public void onError(String error) { + Toast.makeText(CallHistoryActivity.this, "呼叫失败: " + error, Toast.LENGTH_SHORT).show(); + } + }); + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryAdapter.java new file mode 100644 index 00000000..3fb37cc7 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryAdapter.java @@ -0,0 +1,165 @@ +package com.example.livestreaming.call; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; +import com.example.livestreaming.R; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import de.hdodenhof.circleimageview.CircleImageView; + +/** + * 通话记录列表适配器 + */ +public class CallHistoryAdapter extends RecyclerView.Adapter { + + private List records = new ArrayList<>(); + private OnItemClickListener listener; + private SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm", Locale.getDefault()); + private SimpleDateFormat dateFormat = new SimpleDateFormat("MM-dd", Locale.getDefault()); + + public interface OnItemClickListener { + void onItemClick(CallRecordResponse record); + void onCallClick(CallRecordResponse record); + } + + public void setOnItemClickListener(OnItemClickListener listener) { + this.listener = listener; + } + + public void setRecords(List records) { + this.records = records != null ? records : new ArrayList<>(); + notifyDataSetChanged(); + } + + public void addRecords(List newRecords) { + if (newRecords != null && !newRecords.isEmpty()) { + int start = records.size(); + records.addAll(newRecords); + notifyItemRangeInserted(start, newRecords.size()); + } + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_call_record, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + CallRecordResponse record = records.get(position); + holder.bind(record); + } + + @Override + public int getItemCount() { + return records.size(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + CircleImageView ivAvatar; + TextView tvUserName; + ImageView ivCallType; + ImageView ivDirection; + TextView tvStatus; + TextView tvTime; + ImageButton btnCall; + + ViewHolder(@NonNull View itemView) { + super(itemView); + ivAvatar = itemView.findViewById(R.id.ivAvatar); + tvUserName = itemView.findViewById(R.id.tvUserName); + ivCallType = itemView.findViewById(R.id.ivCallType); + ivDirection = itemView.findViewById(R.id.ivDirection); + tvStatus = itemView.findViewById(R.id.tvStatus); + tvTime = itemView.findViewById(R.id.tvTime); + btnCall = itemView.findViewById(R.id.btnCall); + } + + void bind(CallRecordResponse record) { + // 用户名和头像 + tvUserName.setText(record.getOtherUserName() != null ? + record.getOtherUserName() : "用户" + record.getOtherUserId()); + + if (record.getOtherUserAvatar() != null && !record.getOtherUserAvatar().isEmpty()) { + Glide.with(itemView.getContext()) + .load(record.getOtherUserAvatar()) + .into(ivAvatar); + } else { + ivAvatar.setImageResource(R.drawable.ic_default_avatar); + } + + // 通话类型图标 + ivCallType.setImageResource("video".equals(record.getCallType()) ? + R.drawable.ic_videocam : R.drawable.ic_call); + + // 呼入/呼出方向和状态 + String status = record.getStatus(); + boolean isOutgoing = record.isOutgoing(); + + if ("missed".equals(status)) { + ivDirection.setImageResource(R.drawable.ic_call_missed); + tvStatus.setText("未接听"); + tvStatus.setTextColor(itemView.getContext().getColor(R.color.red)); + } else if ("rejected".equals(status)) { + ivDirection.setImageResource(isOutgoing ? R.drawable.ic_call_made : R.drawable.ic_call_received); + tvStatus.setText(isOutgoing ? "对方已拒绝" : "已拒绝"); + tvStatus.setTextColor(itemView.getContext().getColor(R.color.text_secondary)); + } else if ("cancelled".equals(status)) { + ivDirection.setImageResource(R.drawable.ic_call_made); + tvStatus.setText("已取消"); + tvStatus.setTextColor(itemView.getContext().getColor(R.color.text_secondary)); + } else if ("ended".equals(status)) { + ivDirection.setImageResource(isOutgoing ? R.drawable.ic_call_made : R.drawable.ic_call_received); + String durationText = record.getDurationText(); + tvStatus.setText(durationText.isEmpty() ? "已结束" : "通话 " + durationText); + tvStatus.setTextColor(itemView.getContext().getColor(R.color.text_secondary)); + } else { + ivDirection.setImageResource(isOutgoing ? R.drawable.ic_call_made : R.drawable.ic_call_received); + tvStatus.setText(isOutgoing ? "呼出" : "呼入"); + tvStatus.setTextColor(itemView.getContext().getColor(R.color.text_secondary)); + } + + // 时间 + if (record.getCallTime() != null) { + Date callDate = new Date(record.getCallTime()); + Date today = new Date(); + if (isSameDay(callDate, today)) { + tvTime.setText(timeFormat.format(callDate)); + } else { + tvTime.setText(dateFormat.format(callDate)); + } + } + + // 点击事件 + itemView.setOnClickListener(v -> { + if (listener != null) listener.onItemClick(record); + }); + + btnCall.setOnClickListener(v -> { + if (listener != null) listener.onCallClick(record); + }); + } + + private boolean isSameDay(Date date1, Date date2) { + SimpleDateFormat fmt = new SimpleDateFormat("yyyyMMdd", Locale.getDefault()); + return fmt.format(date1).equals(fmt.format(date2)); + } + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryResponse.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryResponse.java new file mode 100644 index 00000000..b3b2b546 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallHistoryResponse.java @@ -0,0 +1,45 @@ +package com.example.livestreaming.call; + +import java.util.List; + +/** + * 通话历史响应 + */ +public class CallHistoryResponse { + private List list; + private int total; + private int pageNum; + private int pageSize; + + public List getList() { + return list; + } + + public void setList(List list) { + this.list = list; + } + + public int getTotal() { + return total; + } + + public void setTotal(int total) { + this.total = total; + } + + public int getPageNum() { + return pageNum; + } + + public void setPageNum(int pageNum) { + this.pageNum = pageNum; + } + + public int getPageSize() { + return pageSize; + } + + public void setPageSize(int pageSize) { + this.pageSize = pageSize; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallManager.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallManager.java new file mode 100644 index 00000000..31b4835b --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallManager.java @@ -0,0 +1,437 @@ +package com.example.livestreaming.call; + +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.example.livestreaming.net.ApiClient; +import com.example.livestreaming.net.AuthStore; + +import org.json.JSONObject; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; + +/** + * 通话管理器 - 单例模式 + */ +public class CallManager implements CallSignalingClient.SignalingListener { + private static final String TAG = "CallManager"; + private static CallManager instance; + + private Context context; + private CallSignalingClient signalingClient; + private CallApiService apiService; + private CallStateListener stateListener; + + private String currentCallId; + private String currentCallType; + private boolean isCaller; + private int otherUserId; + private String otherUserName; + private String otherUserAvatar; + + public interface CallStateListener { + void onCallStateChanged(String state, String callId); + void onIncomingCall(String callId, int callerId, String callerName, String callerAvatar, String callType); + void onCallConnected(String callId); + void onCallEnded(String callId, String reason); + void onError(String error); + } + + private CallManager(Context context) { + this.context = context.getApplicationContext(); + initApiService(); + } + + public static synchronized CallManager getInstance(Context context) { + if (instance == null) { + instance = new CallManager(context); + } + return instance; + } + + private void initApiService() { + String baseUrl = ApiClient.getCurrentBaseUrl(context); + Retrofit retrofit = new Retrofit.Builder() + .baseUrl(baseUrl) + .addConverterFactory(GsonConverterFactory.create()) + .client(ApiClient.getService(context).hashCode() > 0 ? + new okhttp3.OkHttpClient.Builder() + .addInterceptor(chain -> { + String token = AuthStore.getToken(context); + okhttp3.Request.Builder req = chain.request().newBuilder(); + if (token != null && !token.isEmpty()) { + req.header("Authori-zation", token); + req.header("Authorization", token); + } + return chain.proceed(req.build()); + }) + .build() : new okhttp3.OkHttpClient()) + .build(); + apiService = retrofit.create(CallApiService.class); + } + + public void setStateListener(CallStateListener listener) { + this.stateListener = listener; + } + + /** + * 连接信令服务器 + */ + public void connect(int userId) { + Log.d(TAG, "connect() called, userId: " + userId); + if (signalingClient != null && signalingClient.isConnected()) { + Log.d(TAG, "已经连接,跳过"); + return; + } + String baseUrl = ApiClient.getCurrentBaseUrl(context); + Log.d(TAG, "连接信令服务器,baseUrl: " + baseUrl); + signalingClient = new CallSignalingClient(baseUrl, userId); + signalingClient.setListener(this); + signalingClient.connect(); + } + + /** + * 断开信令服务器 + */ + public void disconnect() { + if (signalingClient != null) { + signalingClient.disconnect(); + signalingClient = null; + } + } + + /** + * 发起通话 + */ + public void initiateCall(int calleeId, String callType, CallCallback callback) { + // 确保WebSocket已连接,这样才能收到对方的接听/拒绝通知 + String userId = com.example.livestreaming.net.AuthStore.getUserId(context); + int uid = 0; + if (userId != null && !userId.isEmpty()) { + try { + uid = (int) Double.parseDouble(userId); + } catch (NumberFormatException e) { + Log.e(TAG, "解析用户ID失败", e); + } + } + + final int finalUid = uid; + + // 如果WebSocket未连接,先连接再发起通话 + if (signalingClient == null || !signalingClient.isConnected()) { + Log.d(TAG, "发起通话前连接WebSocket, userId=" + finalUid); + connect(finalUid); + // 延迟发起通话,等待WebSocket连接 + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { + Log.d(TAG, "WebSocket连接状态: " + (signalingClient != null && signalingClient.isConnected())); + doInitiateCall(calleeId, callType, callback); + }, 1000); + } else { + Log.d(TAG, "WebSocket已连接,直接发起通话"); + doInitiateCall(calleeId, callType, callback); + } + } + + private void doInitiateCall(int calleeId, String callType, CallCallback callback) { + InitiateCallRequest request = new InitiateCallRequest(calleeId, callType); + apiService.initiateCall(request).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, + Response> response) { + if (response.isSuccessful() && response.body() != null && response.body().isSuccess()) { + InitiateCallResponse data = response.body().getData(); + currentCallId = data.getCallId(); + currentCallType = data.getCallType(); + isCaller = true; + otherUserId = data.getCalleeId(); + otherUserName = data.getCalleeName(); + otherUserAvatar = data.getCalleeAvatar(); + + Log.d(TAG, "通话创建成功: callId=" + currentCallId + ", WebSocket连接=" + + (signalingClient != null && signalingClient.isConnected())); + + // 启动通话界面 + startCallActivity(true); + + if (callback != null) callback.onSuccess(data); + } else { + String msg = response.body() != null ? response.body().getMessage() : "发起通话失败"; + if (callback != null) callback.onError(msg); + } + } + + @Override + public void onFailure(Call> call, Throwable t) { + Log.e(TAG, "Initiate call failed", t); + if (callback != null) callback.onError(t.getMessage()); + } + }); + } + + /** + * 接听通话 + */ + public void acceptCall(String callId) { + Log.d(TAG, "acceptCall: callId=" + callId); + + // 确保WebSocket已连接 + if (signalingClient == null || !signalingClient.isConnected()) { + Log.w(TAG, "WebSocket未连接,尝试重新连接"); + // 尝试获取当前用户ID并连接 + String userId = com.example.livestreaming.net.AuthStore.getUserId(context); + if (userId != null && !userId.isEmpty()) { + try { + int uid = (int) Double.parseDouble(userId); + connect(uid); + // 延迟发送接听消息,等待连接建立 + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { + if (signalingClient != null && signalingClient.isConnected()) { + signalingClient.sendCallAccept(callId); + Log.d(TAG, "延迟发送接听消息成功"); + } + }, 500); + } catch (NumberFormatException e) { + Log.e(TAG, "解析用户ID失败", e); + } + } + } else { + signalingClient.sendCallAccept(callId); + Log.d(TAG, "发送接听消息"); + } + + apiService.acceptCall(callId).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, Response> response) { + Log.d(TAG, "Accept call API response: success=" + response.isSuccessful() + + ", code=" + response.code() + + ", body=" + (response.body() != null ? response.body().getMessage() : "null")); + } + + @Override + public void onFailure(Call> call, Throwable t) { + Log.e(TAG, "Accept call API failed: " + t.getMessage(), t); + } + }); + } + + /** + * 拒绝通话 + */ + public void rejectCall(String callId) { + if (signalingClient != null) { + signalingClient.sendCallReject(callId); + } + apiService.rejectCall(callId).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, Response> response) { + Log.d(TAG, "Reject call response: " + (response.isSuccessful())); + clearCallState(); + } + + @Override + public void onFailure(Call> call, Throwable t) { + Log.e(TAG, "Reject call failed", t); + clearCallState(); + } + }); + } + + /** + * 取消通话 + */ + public void cancelCall(String callId) { + if (signalingClient != null) { + signalingClient.sendCallCancel(callId); + } + apiService.cancelCall(callId).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, Response> response) { + Log.d(TAG, "Cancel call response: " + (response.isSuccessful())); + clearCallState(); + } + + @Override + public void onFailure(Call> call, Throwable t) { + Log.e(TAG, "Cancel call failed", t); + clearCallState(); + } + }); + } + + /** + * 结束通话 + */ + public void endCall(String callId, String reason) { + if (signalingClient != null) { + signalingClient.sendCallEnd(callId, reason); + } + apiService.endCall(callId, reason).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, Response> response) { + Log.d(TAG, "End call response: " + (response.isSuccessful())); + clearCallState(); + } + + @Override + public void onFailure(Call> call, Throwable t) { + Log.e(TAG, "End call failed", t); + clearCallState(); + } + }); + } + + private void startCallActivity(boolean isCaller) { + Intent intent = new Intent(context, CallActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra("callId", currentCallId); + intent.putExtra("callType", currentCallType); + intent.putExtra("isCaller", isCaller); + intent.putExtra("otherUserId", otherUserId); + intent.putExtra("otherUserName", otherUserName); + intent.putExtra("otherUserAvatar", otherUserAvatar); + context.startActivity(intent); + } + + private void clearCallState() { + currentCallId = null; + currentCallType = null; + isCaller = false; + otherUserId = 0; + otherUserName = null; + otherUserAvatar = null; + } + + public String getCurrentCallId() { + return currentCallId; + } + + public boolean isInCall() { + return currentCallId != null; + } + + // SignalingListener 实现 + @Override + public void onConnected() { + Log.d(TAG, "Signaling connected - WebSocket连接成功并已注册"); + } + + @Override + public void onDisconnected() { + Log.d(TAG, "Signaling disconnected - WebSocket断开连接"); + } + + @Override + public void onError(String error) { + Log.e(TAG, "Signaling error: " + error); + if (stateListener != null) { + stateListener.onError(error); + } + } + + @Override + public void onIncomingCall(String callId, int callerId, String callerName, String callerAvatar, String callType) { + Log.d(TAG, "========== 收到来电通知 =========="); + Log.d(TAG, "callId: " + callId); + Log.d(TAG, "callerId: " + callerId); + Log.d(TAG, "callerName: " + callerName); + Log.d(TAG, "callType: " + callType); + + currentCallId = callId; + currentCallType = callType; + isCaller = false; + otherUserId = callerId; + otherUserName = callerName; + otherUserAvatar = callerAvatar; + + // 启动来电界面 + Log.d(TAG, "启动来电界面 IncomingCallActivity"); + Intent intent = new Intent(context, IncomingCallActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra("callId", callId); + intent.putExtra("callType", callType); + intent.putExtra("callerId", callerId); + intent.putExtra("callerName", callerName); + intent.putExtra("callerAvatar", callerAvatar); + context.startActivity(intent); + + if (stateListener != null) { + stateListener.onIncomingCall(callId, callerId, callerName, callerAvatar, callType); + } + } + + @Override + public void onCallAccepted(String callId) { + Log.d(TAG, "========== 收到通话接听通知 =========="); + Log.d(TAG, "callId: " + callId); + Log.d(TAG, "currentCallId: " + currentCallId); + Log.d(TAG, "stateListener: " + (stateListener != null ? stateListener.getClass().getSimpleName() : "null")); + + if (stateListener != null) { + Log.d(TAG, "调用 stateListener.onCallConnected"); + stateListener.onCallConnected(callId); + } else { + Log.w(TAG, "stateListener 为空,无法通知界面"); + } + } + + @Override + public void onCallRejected(String callId) { + Log.d(TAG, "Call rejected: " + callId); + if (stateListener != null) { + stateListener.onCallEnded(callId, "rejected"); + } + clearCallState(); + } + + @Override + public void onCallCancelled(String callId) { + Log.d(TAG, "Call cancelled: " + callId); + if (stateListener != null) { + stateListener.onCallEnded(callId, "cancelled"); + } + clearCallState(); + } + + @Override + public void onCallEnded(String callId, String reason) { + Log.d(TAG, "Call ended: " + callId + ", reason: " + reason); + if (stateListener != null) { + stateListener.onCallEnded(callId, reason); + } + clearCallState(); + } + + @Override + public void onCallTimeout(String callId) { + Log.d(TAG, "Call timeout: " + callId); + if (stateListener != null) { + stateListener.onCallEnded(callId, "timeout"); + } + clearCallState(); + } + + @Override + public void onOffer(String callId, String sdp) { + // WebRTC offer处理 - 后续实现 + } + + @Override + public void onAnswer(String callId, String sdp) { + // WebRTC answer处理 - 后续实现 + } + + @Override + public void onIceCandidate(String callId, JSONObject candidate) { + // WebRTC ICE candidate处理 - 后续实现 + } + + public interface CallCallback { + void onSuccess(InitiateCallResponse response); + void onError(String error); + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallRecordResponse.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallRecordResponse.java new file mode 100644 index 00000000..bc26514c --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallRecordResponse.java @@ -0,0 +1,108 @@ +package com.example.livestreaming.call; + +/** + * 通话记录响应 + */ +public class CallRecordResponse { + private long id; + private String callId; + private int callerId; + private int calleeId; + private String callType; + private String status; + private Long callTime; + private Long connectTime; + private Long endTime; + private int duration; + private boolean isOutgoing; + private String callerName; + private String callerAvatar; + private String calleeName; + private String calleeAvatar; + private int otherUserId; + private String otherUserName; + private String otherUserAvatar; + + // Getters and Setters + public long getId() { return id; } + public void setId(long id) { this.id = id; } + + public String getCallId() { return callId; } + public void setCallId(String callId) { this.callId = callId; } + + public int getCallerId() { return callerId; } + public void setCallerId(int callerId) { this.callerId = callerId; } + + public int getCalleeId() { return calleeId; } + public void setCalleeId(int calleeId) { this.calleeId = calleeId; } + + public String getCallType() { return callType; } + public void setCallType(String callType) { this.callType = callType; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public Long getCallTime() { return callTime; } + public void setCallTime(Long callTime) { this.callTime = callTime; } + + public Long getConnectTime() { return connectTime; } + public void setConnectTime(Long connectTime) { this.connectTime = connectTime; } + + public Long getEndTime() { return endTime; } + public void setEndTime(Long endTime) { this.endTime = endTime; } + + public int getDuration() { return duration; } + public void setDuration(int duration) { this.duration = duration; } + + public boolean isOutgoing() { return isOutgoing; } + public void setOutgoing(boolean outgoing) { isOutgoing = outgoing; } + + public String getCallerName() { return callerName; } + public void setCallerName(String callerName) { this.callerName = callerName; } + + public String getCallerAvatar() { return callerAvatar; } + public void setCallerAvatar(String callerAvatar) { this.callerAvatar = callerAvatar; } + + public String getCalleeName() { return calleeName; } + public void setCalleeName(String calleeName) { this.calleeName = calleeName; } + + public String getCalleeAvatar() { return calleeAvatar; } + public void setCalleeAvatar(String calleeAvatar) { this.calleeAvatar = calleeAvatar; } + + public int getOtherUserId() { return otherUserId; } + public void setOtherUserId(int otherUserId) { this.otherUserId = otherUserId; } + + public String getOtherUserName() { return otherUserName; } + public void setOtherUserName(String otherUserName) { this.otherUserName = otherUserName; } + + public String getOtherUserAvatar() { return otherUserAvatar; } + public void setOtherUserAvatar(String otherUserAvatar) { this.otherUserAvatar = otherUserAvatar; } + + /** + * 获取状态显示文本 + */ + public String getStatusText() { + if (status == null) return ""; + switch (status) { + case "ended": return "已结束"; + case "missed": return "未接听"; + case "rejected": return "已拒绝"; + case "cancelled": return "已取消"; + case "busy": return "对方忙"; + default: return status; + } + } + + /** + * 获取通话时长显示文本 + */ + public String getDurationText() { + if (duration <= 0) return ""; + int minutes = duration / 60; + int seconds = duration % 60; + if (minutes > 0) { + return String.format("%d分%d秒", minutes, seconds); + } + return String.format("%d秒", seconds); + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/CallSignalingClient.java b/android-app/app/src/main/java/com/example/livestreaming/call/CallSignalingClient.java new file mode 100644 index 00000000..43706c24 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/CallSignalingClient.java @@ -0,0 +1,379 @@ +package com.example.livestreaming.call; + +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; + +/** + * 通话信令WebSocket客户端 + */ +public class CallSignalingClient { + private static final String TAG = "CallSignalingClient"; + + private WebSocket webSocket; + private OkHttpClient client; + private SignalingListener listener; + private Handler mainHandler; + private String baseUrl; + private int userId; + private boolean isConnected = false; + + public interface SignalingListener { + void onConnected(); + void onDisconnected(); + void onError(String error); + void onIncomingCall(String callId, int callerId, String callerName, String callerAvatar, String callType); + void onCallAccepted(String callId); + void onCallRejected(String callId); + void onCallCancelled(String callId); + void onCallEnded(String callId, String reason); + void onCallTimeout(String callId); + void onOffer(String callId, String sdp); + void onAnswer(String callId, String sdp); + void onIceCandidate(String callId, JSONObject candidate); + } + + public CallSignalingClient(String baseUrl, int userId) { + this.baseUrl = baseUrl; + this.userId = userId; + this.client = new OkHttpClient(); + this.mainHandler = new Handler(Looper.getMainLooper()); + } + + public void setListener(SignalingListener listener) { + this.listener = listener; + } + + public void connect() { + String wsUrl = baseUrl.replace("http://", "ws://").replace("https://", "wss://"); + if (!wsUrl.endsWith("/")) wsUrl += "/"; + wsUrl += "ws/call"; + + Log.d(TAG, "Connecting to: " + wsUrl); + + Request request = new Request.Builder().url(wsUrl).build(); + webSocket = client.newWebSocket(request, new WebSocketListener() { + @Override + public void onOpen(WebSocket webSocket, Response response) { + Log.d(TAG, "WebSocket connected"); + isConnected = true; + register(); + notifyConnected(); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + Log.d(TAG, "Received: " + text); + handleMessage(text); + } + + @Override + public void onClosing(WebSocket webSocket, int code, String reason) { + Log.d(TAG, "WebSocket closing: " + reason); + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + Log.d(TAG, "WebSocket closed: " + reason); + isConnected = false; + notifyDisconnected(); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + Log.e(TAG, "WebSocket error", t); + isConnected = false; + notifyError(t.getMessage()); + } + }); + } + + public void disconnect() { + if (webSocket != null) { + webSocket.close(1000, "User disconnect"); + webSocket = null; + } + isConnected = false; + } + + public boolean isConnected() { + return isConnected; + } + + private void register() { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "register"); + msg.put("userId", userId); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Register error", e); + } + } + + public void sendCallRequest(int calleeId, String callType) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "call_request"); + msg.put("calleeId", calleeId); + msg.put("callType", callType); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send call request error", e); + } + } + + public void sendCallAccept(String callId) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "call_accept"); + msg.put("callId", callId); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send call accept error", e); + } + } + + public void sendCallReject(String callId) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "call_reject"); + msg.put("callId", callId); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send call reject error", e); + } + } + + public void sendCallCancel(String callId) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "call_cancel"); + msg.put("callId", callId); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send call cancel error", e); + } + } + + public void sendCallEnd(String callId, String reason) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "call_end"); + msg.put("callId", callId); + msg.put("reason", reason != null ? reason : "normal"); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send call end error", e); + } + } + + public void sendOffer(String callId, String sdp) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "offer"); + msg.put("callId", callId); + msg.put("sdp", sdp); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send offer error", e); + } + } + + public void sendAnswer(String callId, String sdp) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "answer"); + msg.put("callId", callId); + msg.put("sdp", sdp); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send answer error", e); + } + } + + public void sendIceCandidate(String callId, JSONObject candidate) { + try { + JSONObject msg = new JSONObject(); + msg.put("type", "ice-candidate"); + msg.put("callId", callId); + msg.put("candidate", candidate); + send(msg); + } catch (JSONException e) { + Log.e(TAG, "Send ICE candidate error", e); + } + } + + private void send(JSONObject msg) { + if (webSocket != null && isConnected) { + String text = msg.toString(); + Log.d(TAG, "Sending: " + text); + webSocket.send(text); + } + } + + private void handleMessage(String text) { + try { + JSONObject json = new JSONObject(text); + String type = json.optString("type", ""); + + switch (type) { + case "registered": + Log.d(TAG, "Registered successfully"); + break; + case "incoming_call": + handleIncomingCall(json); + break; + case "call_created": + // 通话创建成功,等待对方接听 + break; + case "call_accepted": + handleCallAccepted(json); + break; + case "call_rejected": + handleCallRejected(json); + break; + case "call_cancelled": + handleCallCancelled(json); + break; + case "call_ended": + handleCallEnded(json); + break; + case "call_timeout": + handleCallTimeout(json); + break; + case "offer": + handleOffer(json); + break; + case "answer": + handleAnswer(json); + break; + case "ice-candidate": + handleIceCandidate(json); + break; + case "error": + notifyError(json.optString("message", "Unknown error")); + break; + } + } catch (JSONException e) { + Log.e(TAG, "Parse message error", e); + } + } + + private void handleIncomingCall(JSONObject json) { + String callId = json.optString("callId"); + int callerId = json.optInt("callerId"); + String callerName = json.optString("callerName"); + String callerAvatar = json.optString("callerAvatar"); + String callType = json.optString("callType"); + + mainHandler.post(() -> { + if (listener != null) { + listener.onIncomingCall(callId, callerId, callerName, callerAvatar, callType); + } + }); + } + + private void handleCallAccepted(JSONObject json) { + String callId = json.optString("callId"); + mainHandler.post(() -> { + if (listener != null) { + listener.onCallAccepted(callId); + } + }); + } + + private void handleCallRejected(JSONObject json) { + String callId = json.optString("callId"); + mainHandler.post(() -> { + if (listener != null) { + listener.onCallRejected(callId); + } + }); + } + + private void handleCallCancelled(JSONObject json) { + String callId = json.optString("callId"); + mainHandler.post(() -> { + if (listener != null) { + listener.onCallCancelled(callId); + } + }); + } + + private void handleCallEnded(JSONObject json) { + String callId = json.optString("callId"); + String reason = json.optString("reason", "normal"); + mainHandler.post(() -> { + if (listener != null) { + listener.onCallEnded(callId, reason); + } + }); + } + + private void handleCallTimeout(JSONObject json) { + String callId = json.optString("callId"); + mainHandler.post(() -> { + if (listener != null) { + listener.onCallTimeout(callId); + } + }); + } + + private void handleOffer(JSONObject json) throws JSONException { + String callId = json.optString("callId"); + String sdp = json.optString("sdp"); + mainHandler.post(() -> { + if (listener != null) { + listener.onOffer(callId, sdp); + } + }); + } + + private void handleAnswer(JSONObject json) throws JSONException { + String callId = json.optString("callId"); + String sdp = json.optString("sdp"); + mainHandler.post(() -> { + if (listener != null) { + listener.onAnswer(callId, sdp); + } + }); + } + + private void handleIceCandidate(JSONObject json) throws JSONException { + String callId = json.optString("callId"); + JSONObject candidate = json.optJSONObject("candidate"); + mainHandler.post(() -> { + if (listener != null) { + listener.onIceCandidate(callId, candidate); + } + }); + } + + private void notifyConnected() { + mainHandler.post(() -> { + if (listener != null) listener.onConnected(); + }); + } + + private void notifyDisconnected() { + mainHandler.post(() -> { + if (listener != null) listener.onDisconnected(); + }); + } + + private void notifyError(String error) { + mainHandler.post(() -> { + if (listener != null) listener.onError(error); + }); + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/IncomingCallActivity.java b/android-app/app/src/main/java/com/example/livestreaming/call/IncomingCallActivity.java new file mode 100644 index 00000000..46c4da60 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/IncomingCallActivity.java @@ -0,0 +1,216 @@ +package com.example.livestreaming.call; + +import android.content.Intent; +import android.media.Ringtone; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Vibrator; +import android.view.WindowManager; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; + +import com.bumptech.glide.Glide; +import com.example.livestreaming.R; + +/** + * 来电界面 + */ +public class IncomingCallActivity extends AppCompatActivity implements CallManager.CallStateListener { + + private ImageView ivBackgroundAvatar; + private ImageView ivAvatar; + private TextView tvUserName; + private TextView tvCallType; + private ImageButton btnReject; + private ImageButton btnAccept; + + private CallManager callManager; + private Ringtone ringtone; + private Vibrator vibrator; + + private String callId; + private String callType; + private int callerId; + private String callerName; + private String callerAvatar; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // 显示在锁屏上方,保持屏幕常亮 + getWindow().addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON | + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED | + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON | + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + ); + + setContentView(R.layout.activity_incoming_call); + + initViews(); + initData(); + initCallManager(); + setupListeners(); + updateUI(); + startRinging(); + } + + private void initViews() { + ivBackgroundAvatar = findViewById(R.id.ivBackgroundAvatar); + ivAvatar = findViewById(R.id.ivAvatar); + tvUserName = findViewById(R.id.tvUserName); + tvCallType = findViewById(R.id.tvCallType); + btnReject = findViewById(R.id.btnReject); + btnAccept = findViewById(R.id.btnAccept); + } + + private void initData() { + callId = getIntent().getStringExtra("callId"); + callType = getIntent().getStringExtra("callType"); + callerId = getIntent().getIntExtra("callerId", 0); + callerName = getIntent().getStringExtra("callerName"); + callerAvatar = getIntent().getStringExtra("callerAvatar"); + } + + private void initCallManager() { + callManager = CallManager.getInstance(this); + callManager.setStateListener(this); + + // 确保WebSocket已连接(被叫方需要发送接听/拒绝消息) + String userId = com.example.livestreaming.net.AuthStore.getUserId(this); + if (userId != null && !userId.isEmpty()) { + try { + int uid = (int) Double.parseDouble(userId); + if (uid > 0) { + android.util.Log.d("IncomingCallActivity", "确保WebSocket连接,userId: " + uid); + callManager.connect(uid); + } + } catch (NumberFormatException e) { + android.util.Log.e("IncomingCallActivity", "解析用户ID失败", e); + } + } + } + + private void setupListeners() { + btnReject.setOnClickListener(v -> { + stopRinging(); + callManager.rejectCall(callId); + finish(); + }); + + btnAccept.setOnClickListener(v -> { + stopRinging(); + callManager.acceptCall(callId); + + // 跳转到通话界面,并标记为已接通 + Intent intent = new Intent(this, CallActivity.class); + intent.putExtra("callId", callId); + intent.putExtra("callType", callType); + intent.putExtra("isCaller", false); + intent.putExtra("otherUserId", callerId); + intent.putExtra("otherUserName", callerName); + intent.putExtra("otherUserAvatar", callerAvatar); + intent.putExtra("isConnected", true); // 被叫方接听后直接进入通话状态 + startActivity(intent); + finish(); + }); + } + + private void updateUI() { + tvUserName.setText(callerName != null ? callerName : "用户" + callerId); + tvCallType.setText("video".equals(callType) ? "视频来电" : "语音来电"); + + if (callerAvatar != null && !callerAvatar.isEmpty()) { + Glide.with(this).load(callerAvatar).into(ivAvatar); + Glide.with(this).load(callerAvatar).into(ivBackgroundAvatar); + } + } + + private void startRinging() { + // 播放铃声 + try { + Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); + ringtone = RingtoneManager.getRingtone(this, ringtoneUri); + if (ringtone != null) { + ringtone.play(); + } + } catch (Exception e) { + e.printStackTrace(); + } + + // 震动 + try { + vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE); + if (vibrator != null && vibrator.hasVibrator()) { + long[] pattern = {0, 1000, 1000}; + vibrator.vibrate(pattern, 0); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + private void stopRinging() { + if (ringtone != null && ringtone.isPlaying()) { + ringtone.stop(); + } + if (vibrator != null) { + vibrator.cancel(); + } + } + + // CallStateListener 实现 + @Override + public void onCallStateChanged(String state, String callId) { + } + + @Override + public void onIncomingCall(String callId, int callerId, String callerName, String callerAvatar, String callType) { + } + + @Override + public void onCallConnected(String callId) { + } + + @Override + public void onCallEnded(String callId, String reason) { + runOnUiThread(() -> { + stopRinging(); + if ("cancelled".equals(reason)) { + Toast.makeText(this, "对方已取消", Toast.LENGTH_SHORT).show(); + } else if ("timeout".equals(reason)) { + Toast.makeText(this, "来电已超时", Toast.LENGTH_SHORT).show(); + } + finish(); + }); + } + + @Override + public void onError(String error) { + runOnUiThread(() -> { + stopRinging(); + Toast.makeText(this, "错误: " + error, Toast.LENGTH_SHORT).show(); + finish(); + }); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + stopRinging(); + if (callManager != null) { + callManager.setStateListener(null); + } + } + + @Override + public void onBackPressed() { + // 禁止返回键,必须接听或拒绝 + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/InitiateCallRequest.java b/android-app/app/src/main/java/com/example/livestreaming/call/InitiateCallRequest.java new file mode 100644 index 00000000..9d849209 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/InitiateCallRequest.java @@ -0,0 +1,30 @@ +package com.example.livestreaming.call; + +/** + * 发起通话请求 + */ +public class InitiateCallRequest { + private int calleeId; + private String callType; // "voice" 或 "video" + + public InitiateCallRequest(int calleeId, String callType) { + this.calleeId = calleeId; + this.callType = callType; + } + + public int getCalleeId() { + return calleeId; + } + + public void setCalleeId(int calleeId) { + this.calleeId = calleeId; + } + + public String getCallType() { + return callType; + } + + public void setCallType(String callType) { + this.callType = callType; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/call/InitiateCallResponse.java b/android-app/app/src/main/java/com/example/livestreaming/call/InitiateCallResponse.java new file mode 100644 index 00000000..b360c7a7 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/call/InitiateCallResponse.java @@ -0,0 +1,70 @@ +package com.example.livestreaming.call; + +/** + * 发起通话响应 + */ +public class InitiateCallResponse { + private String callId; + private String callType; + private int calleeId; + private String calleeName; + private String calleeAvatar; + private String status; + private String signalingUrl; + + public String getCallId() { + return callId; + } + + public void setCallId(String callId) { + this.callId = callId; + } + + public String getCallType() { + return callType; + } + + public void setCallType(String callType) { + this.callType = callType; + } + + public int getCalleeId() { + return calleeId; + } + + public void setCalleeId(int calleeId) { + this.calleeId = calleeId; + } + + public String getCalleeName() { + return calleeName; + } + + public void setCalleeName(String calleeName) { + this.calleeName = calleeName; + } + + public String getCalleeAvatar() { + return calleeAvatar; + } + + public void setCalleeAvatar(String calleeAvatar) { + this.calleeAvatar = calleeAvatar; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getSignalingUrl() { + return signalingUrl; + } + + public void setSignalingUrl(String signalingUrl) { + this.signalingUrl = signalingUrl; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/ConversationResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/ConversationResponse.java new file mode 100644 index 00000000..6c4b9b69 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/ConversationResponse.java @@ -0,0 +1,39 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; + +public class ConversationResponse { + + @SerializedName("id") + private String id; + + @SerializedName("title") + private String title; + + @SerializedName("lastMessage") + private String lastMessage; + + @SerializedName("timeText") + private String timeText; + + @SerializedName("unreadCount") + private Integer unreadCount; + + @SerializedName("muted") + private Boolean muted; + + @SerializedName("avatarUrl") + private String avatarUrl; + + @SerializedName("otherUserId") + private Integer otherUserId; + + public String getId() { return id; } + public String getTitle() { return title; } + public String getLastMessage() { return lastMessage; } + public String getTimeText() { return timeText; } + public Integer getUnreadCount() { return unreadCount; } + public Boolean getMuted() { return muted; } + public String getAvatarUrl() { return avatarUrl; } + public Integer getOtherUserId() { return otherUserId; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/CreateRechargeRequest.java b/android-app/app/src/main/java/com/example/livestreaming/net/CreateRechargeRequest.java new file mode 100644 index 00000000..57fe8b4e --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/CreateRechargeRequest.java @@ -0,0 +1,26 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; +import java.math.BigDecimal; + +public class CreateRechargeRequest { + + @SerializedName("optionId") + private Integer optionId; + + @SerializedName("coinAmount") + private BigDecimal coinAmount; + + @SerializedName("price") + private BigDecimal price; + + public CreateRechargeRequest(Integer optionId, BigDecimal coinAmount, BigDecimal price) { + this.optionId = optionId; + this.coinAmount = coinAmount; + this.price = price; + } + + public Integer getOptionId() { return optionId; } + public BigDecimal getCoinAmount() { return coinAmount; } + public BigDecimal getPrice() { return price; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/CreateRechargeResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/CreateRechargeResponse.java new file mode 100644 index 00000000..63a29405 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/CreateRechargeResponse.java @@ -0,0 +1,15 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; + +public class CreateRechargeResponse { + + @SerializedName("orderId") + private String orderId; + + @SerializedName("paymentUrl") + private String paymentUrl; + + public String getOrderId() { return orderId; } + public String getPaymentUrl() { return paymentUrl; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/FileUploadResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/FileUploadResponse.java new file mode 100644 index 00000000..f8c7c5c5 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/FileUploadResponse.java @@ -0,0 +1,19 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; + +public class FileUploadResponse { + + @SerializedName("url") + private String url; + + @SerializedName("fileName") + private String fileName; + + @SerializedName("type") + private String type; + + public String getUrl() { return url; } + public String getFileName() { return fileName; } + public String getType() { return type; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/FriendRequestResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/FriendRequestResponse.java new file mode 100644 index 00000000..865e1fff --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/FriendRequestResponse.java @@ -0,0 +1,35 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; + +public class FriendRequestResponse { + + @SerializedName("id") + private Long id; + + @SerializedName("from_user_id") + private Integer fromUserId; + + @SerializedName("nickname") + private String nickname; + + @SerializedName("avatarUrl") + private String avatarUrl; + + @SerializedName("phone") + private String phone; + + @SerializedName("message") + private String message; + + @SerializedName("create_time") + private String createTime; + + public Long getId() { return id; } + public Integer getFromUserId() { return fromUserId; } + public String getNickname() { return nickname; } + public String getAvatarUrl() { return avatarUrl; } + public String getPhone() { return phone; } + public String getMessage() { return message; } + public String getCreateTime() { return createTime; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/FriendResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/FriendResponse.java new file mode 100644 index 00000000..5a0e4ae4 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/FriendResponse.java @@ -0,0 +1,31 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; + +public class FriendResponse { + + @SerializedName("id") + private Integer id; + + @SerializedName("name") + private String name; + + @SerializedName("avatarUrl") + private String avatarUrl; + + @SerializedName("phone") + private String phone; + + @SerializedName("isOnline") + private Integer isOnline; + + @SerializedName("lastOnlineTime") + private String lastOnlineTime; + + public Integer getId() { return id; } + public String getName() { return name; } + public String getAvatarUrl() { return avatarUrl; } + public String getPhone() { return phone; } + public boolean isOnline() { return isOnline != null && isOnline == 1; } + public String getLastOnlineTime() { return lastOnlineTime; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/GiftResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/GiftResponse.java new file mode 100644 index 00000000..cc0e40a7 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/GiftResponse.java @@ -0,0 +1,32 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; +import java.math.BigDecimal; + +public class GiftResponse { + + @SerializedName("id") + private String id; + + @SerializedName("name") + private String name; + + @SerializedName("price") + private BigDecimal price; + + @SerializedName("iconUrl") + private String iconUrl; + + @SerializedName("description") + private String description; + + @SerializedName("level") + private Integer level; + + public String getId() { return id; } + public String getName() { return name; } + public BigDecimal getPrice() { return price; } + public String getIconUrl() { return iconUrl; } + public String getDescription() { return description; } + public Integer getLevel() { return level; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/PageResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/PageResponse.java new file mode 100644 index 00000000..a7cfb5b1 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/PageResponse.java @@ -0,0 +1,32 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; +import java.util.List; + +public class PageResponse { + + @SerializedName("list") + private List list; + + @SerializedName("total") + private Long total; + + @SerializedName("page") + private Integer page; + + @SerializedName("limit") + private Integer limit; + + @SerializedName("totalPage") + private Integer totalPage; + + public List getList() { return list; } + public Long getTotal() { return total; } + public Integer getPage() { return page; } + public Integer getLimit() { return limit; } + public Integer getTotalPage() { return totalPage; } + + public boolean hasMore() { + return page != null && totalPage != null && page < totalPage; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/PrivateMessageResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/PrivateMessageResponse.java new file mode 100644 index 00000000..8dfa5306 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/PrivateMessageResponse.java @@ -0,0 +1,39 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; + +public class PrivateMessageResponse { + + @SerializedName("messageId") + private String messageId; + + @SerializedName("userId") + private Integer userId; + + @SerializedName("username") + private String username; + + @SerializedName("avatarUrl") + private String avatarUrl; + + @SerializedName("message") + private String message; + + @SerializedName("timestamp") + private Long timestamp; + + @SerializedName("status") + private String status; + + @SerializedName("isSystemMessage") + private Boolean isSystemMessage; + + public String getMessageId() { return messageId; } + public Integer getUserId() { return userId; } + public String getUsername() { return username; } + public String getAvatarUrl() { return avatarUrl; } + public String getMessage() { return message; } + public Long getTimestamp() { return timestamp; } + public String getStatus() { return status; } + public Boolean getIsSystemMessage() { return isSystemMessage; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/RechargeOptionResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/RechargeOptionResponse.java new file mode 100644 index 00000000..0f567837 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/RechargeOptionResponse.java @@ -0,0 +1,24 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; +import java.math.BigDecimal; + +public class RechargeOptionResponse { + + @SerializedName("id") + private String id; + + @SerializedName("coinAmount") + private BigDecimal coinAmount; + + @SerializedName("price") + private BigDecimal price; + + @SerializedName("discountLabel") + private String discountLabel; + + public String getId() { return id; } + public BigDecimal getCoinAmount() { return coinAmount; } + public BigDecimal getPrice() { return price; } + public String getDiscountLabel() { return discountLabel; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/SearchUserResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/SearchUserResponse.java new file mode 100644 index 00000000..7b892b07 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/SearchUserResponse.java @@ -0,0 +1,27 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; + +public class SearchUserResponse { + + @SerializedName("id") + private Integer id; + + @SerializedName("nickname") + private String nickname; + + @SerializedName("phone") + private String phone; + + @SerializedName("avatarUrl") + private String avatarUrl; + + @SerializedName("friendStatus") + private Integer friendStatus; // 0=未添加, 1=已是好友, 2=已申请待审核 + + public Integer getId() { return id; } + public String getNickname() { return nickname; } + public String getPhone() { return phone; } + public String getAvatarUrl() { return avatarUrl; } + public Integer getFriendStatus() { return friendStatus; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/SendGiftRequest.java b/android-app/app/src/main/java/com/example/livestreaming/net/SendGiftRequest.java new file mode 100644 index 00000000..3707b9d1 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/SendGiftRequest.java @@ -0,0 +1,25 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; + +public class SendGiftRequest { + + @SerializedName("giftId") + private Integer giftId; + + @SerializedName("streamerId") + private Integer streamerId; + + @SerializedName("count") + private Integer count; + + public SendGiftRequest(Integer giftId, Integer streamerId, Integer count) { + this.giftId = giftId; + this.streamerId = streamerId; + this.count = count; + } + + public Integer getGiftId() { return giftId; } + public Integer getStreamerId() { return streamerId; } + public Integer getCount() { return count; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/SendGiftResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/SendGiftResponse.java new file mode 100644 index 00000000..0316f433 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/SendGiftResponse.java @@ -0,0 +1,20 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; +import java.math.BigDecimal; + +public class SendGiftResponse { + + @SerializedName("success") + private Boolean success; + + @SerializedName("newBalance") + private BigDecimal newBalance; + + @SerializedName("message") + private String message; + + public Boolean getSuccess() { return success; } + public BigDecimal getNewBalance() { return newBalance; } + public String getMessage() { return message; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/SendMessageRequest.java b/android-app/app/src/main/java/com/example/livestreaming/net/SendMessageRequest.java new file mode 100644 index 00000000..bc939bc5 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/SendMessageRequest.java @@ -0,0 +1,25 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; + +public class SendMessageRequest { + + @SerializedName("message") + private String message; + + @SerializedName("messageType") + private String messageType; + + public SendMessageRequest(String message) { + this.message = message; + this.messageType = "text"; + } + + public SendMessageRequest(String message, String messageType) { + this.message = message; + this.messageType = messageType; + } + + public String getMessage() { return message; } + public String getMessageType() { return messageType; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/UserBalanceResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/UserBalanceResponse.java new file mode 100644 index 00000000..05168bbd --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/UserBalanceResponse.java @@ -0,0 +1,12 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; +import java.math.BigDecimal; + +public class UserBalanceResponse { + + @SerializedName("coinBalance") + private BigDecimal coinBalance; + + public BigDecimal getCoinBalance() { return coinBalance; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/UserEditRequest.java b/android-app/app/src/main/java/com/example/livestreaming/net/UserEditRequest.java new file mode 100644 index 00000000..4a4d08c9 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/UserEditRequest.java @@ -0,0 +1,24 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; + +public class UserEditRequest { + + @SerializedName("nickname") + private String nickname; + + @SerializedName("avatar") + private String avatar; + + public UserEditRequest() {} + + public UserEditRequest(String nickname, String avatar) { + this.nickname = nickname; + this.avatar = avatar; + } + + public void setNickname(String nickname) { this.nickname = nickname; } + public void setAvatar(String avatar) { this.avatar = avatar; } + public String getNickname() { return nickname; } + public String getAvatar() { return avatar; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/UserInfoResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/UserInfoResponse.java new file mode 100644 index 00000000..b4812f76 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/UserInfoResponse.java @@ -0,0 +1,68 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; +import java.math.BigDecimal; + +public class UserInfoResponse { + + @SerializedName("nickname") + private String nickname; + + @SerializedName("avatar") + private String avatar; + + @SerializedName("phone") + private String phone; + + @SerializedName("nowMoney") + private BigDecimal nowMoney; + + @SerializedName("integral") + private Integer integral; + + @SerializedName("experience") + private Integer experience; + + @SerializedName("brokeragePrice") + private BigDecimal brokeragePrice; + + @SerializedName("level") + private Integer level; + + @SerializedName("isPromoter") + private Boolean isPromoter; + + @SerializedName("couponCount") + private Integer couponCount; + + @SerializedName("vip") + private Boolean vip; + + @SerializedName("vipIcon") + private String vipIcon; + + @SerializedName("vipName") + private String vipName; + + @SerializedName("rechargeSwitch") + private Boolean rechargeSwitch; + + @SerializedName("collectCount") + private Integer collectCount; + + public String getNickname() { return nickname; } + public String getAvatar() { return avatar; } + public String getPhone() { return phone; } + public BigDecimal getNowMoney() { return nowMoney; } + public Integer getIntegral() { return integral; } + public Integer getExperience() { return experience; } + public BigDecimal getBrokeragePrice() { return brokeragePrice; } + public Integer getLevel() { return level; } + public Boolean getIsPromoter() { return isPromoter; } + public Integer getCouponCount() { return couponCount; } + public Boolean getVip() { return vip; } + public String getVipIcon() { return vipIcon; } + public String getVipName() { return vipName; } + public Boolean getRechargeSwitch() { return rechargeSwitch; } + public Integer getCollectCount() { return collectCount; } +} diff --git a/android-app/app/src/main/res/drawable/bg_accept_button.xml b/android-app/app/src/main/res/drawable/bg_accept_button.xml new file mode 100644 index 00000000..0994e54d --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_accept_button.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/bg_call_button.xml b/android-app/app/src/main/res/drawable/bg_call_button.xml new file mode 100644 index 00000000..bc4f0561 --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_call_button.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/bg_call_button_active.xml b/android-app/app/src/main/res/drawable/bg_call_button_active.xml new file mode 100644 index 00000000..9f3c01df --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_call_button_active.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/bg_hangup_button.xml b/android-app/app/src/main/res/drawable/bg_hangup_button.xml new file mode 100644 index 00000000..1ebbe3b8 --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_hangup_button.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_arrow_back.xml b/android-app/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 00000000..14fb7c89 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_call.xml b/android-app/app/src/main/res/drawable/ic_call.xml new file mode 100644 index 00000000..fe445bf9 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_call.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_call_end.xml b/android-app/app/src/main/res/drawable/ic_call_end.xml new file mode 100644 index 00000000..7f9b18d8 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_call_end.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_call_made.xml b/android-app/app/src/main/res/drawable/ic_call_made.xml new file mode 100644 index 00000000..63cade51 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_call_made.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_call_missed.xml b/android-app/app/src/main/res/drawable/ic_call_missed.xml new file mode 100644 index 00000000..191d4cf8 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_call_missed.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_call_received.xml b/android-app/app/src/main/res/drawable/ic_call_received.xml new file mode 100644 index 00000000..558b445e --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_call_received.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_default_avatar.xml b/android-app/app/src/main/res/drawable/ic_default_avatar.xml new file mode 100644 index 00000000..ec8bbc43 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_default_avatar.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/ic_mic.xml b/android-app/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 00000000..39ba5e21 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_mic_off.xml b/android-app/app/src/main/res/drawable/ic_mic_off.xml new file mode 100644 index 00000000..ed56bce5 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_mic_off.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_speaker.xml b/android-app/app/src/main/res/drawable/ic_speaker.xml new file mode 100644 index 00000000..f5a15786 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_speaker.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_switch_camera.xml b/android-app/app/src/main/res/drawable/ic_switch_camera.xml new file mode 100644 index 00000000..1236d5e9 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_switch_camera.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_videocam.xml b/android-app/app/src/main/res/drawable/ic_videocam.xml new file mode 100644 index 00000000..57d3e17a --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_videocam.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/layout/activity_call.xml b/android-app/app/src/main/res/layout/activity_call.xml new file mode 100644 index 00000000..ca8d27b5 --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_call.xml @@ -0,0 +1,239 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/activity_call_history.xml b/android-app/app/src/main/res/layout/activity_call_history.xml new file mode 100644 index 00000000..d0c7098a --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_call_history.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/activity_incoming_call.xml b/android-app/app/src/main/res/layout/activity_incoming_call.xml new file mode 100644 index 00000000..ac3d6a19 --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_incoming_call.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/item_call_record.xml b/android-app/app/src/main/res/layout/item_call_record.xml new file mode 100644 index 00000000..e7550499 --- /dev/null +++ b/android-app/app/src/main/res/layout/item_call_record.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/接口文档.md b/android-app/接口文档.md new file mode 100644 index 00000000..74107bb1 --- /dev/null +++ b/android-app/接口文档.md @@ -0,0 +1,305 @@ +# 直播APP接口文档 + +> 后端服务地址: `http://localhost:8081` +> 所有接口需要在Header中携带 `Authori-zation: {token}` (登录后获取) + +--- + +## 一、用户认证 + +### 1.1 账号密码登录 +- **POST** `/api/front/login` +- **请求体**: +```json +{ + "account": "手机号", + "password": "密码" +} +``` +- **响应**: `token`, `uid`, `nikeName`, `phone` + +### 1.2 用户注册 +- **POST** `/api/front/register` +- **请求体**: +```json +{ + "phone": "手机号", + "password": "密码", + "verificationCode": "验证码", + "nickname": "昵称" +} +``` + +### 1.3 发送验证码 +- **POST** `/api/front/sendCode` +- **参数**: `phone` (表单) + +### 1.4 退出登录 +- **GET** `/api/front/logout` + +--- + +## 二、用户信息 + +### 2.1 获取用户信息 +- **GET** `/api/front/user` +- **响应**: `nickname`, `avatar`, `phone`, `nowMoney`, `integral`, `level`, `vip` 等 + +### 2.2 修改用户资料 +- **POST** `/api/front/user/edit` +- **请求体**: +```json +{ + "nickname": "昵称", + "avatar": "头像URL" +} +``` + +--- + +## 三、直播间 + +### 3.1 获取直播间列表 +- **GET** `/api/front/live/public/rooms` +- **响应**: 直播中的房间列表 + +### 3.2 创建直播间 +- **POST** `/api/front/live/rooms` +- **请求体**: +```json +{ + "title": "直播标题", + "streamerName": "主播名称" +} +``` +- **响应**: 房间信息 + 推流地址 + +### 3.3 获取直播间详情 +- **GET** `/api/front/live/public/rooms/{id}` + +### 3.4 删除直播间 +- **DELETE** `/api/front/live/rooms/{id}` + +### 3.5 获取观看人数 +- **GET** `/api/front/live/public/rooms/{roomId}/viewers/count` + +### 3.6 关注/取消关注主播 +- **POST** `/api/front/live/follow` +- **请求体**: +```json +{ + "streamerId": 123, + "action": "follow" +} +``` +> action 可选值: "follow" 或 "unfollow" + +--- + +## 四、直播弹幕 + +### 4.1 获取弹幕消息 +- **GET** `/api/front/live/public/rooms/{roomId}/messages` +- **参数**: `limit` (数量限制) + +### 4.2 发送弹幕 +- **POST** `/api/front/live/public/rooms/{roomId}/messages` +- **请求体**: +```json +{ + "message": "弹幕内容", + "visitorId": "访客ID", + "nickname": "昵称" +} +``` + +--- + +## 五、礼物打赏 + +### 5.1 获取礼物列表 +- **GET** `/api/front/gift/list` +- **响应**: `id`, `name`, `price`, `iconUrl`, `description`, `level` + +### 5.2 获取用户余额 +- **GET** `/api/front/gift/balance` +- **响应**: `coinBalance` + +### 5.3 赠送礼物 +- **POST** `/api/front/gift/send` +- **请求体**: +```json +{ + "giftId": 1, + "streamerId": 123, + "count": 1 +} +``` +- **响应**: `success`, `newBalance`, `message` + +### 5.4 获取充值选项 +- **GET** `/api/front/gift/recharge/options` +- **响应**: `id`, `coinAmount`, `price`, `discountLabel` + +### 5.5 创建充值订单 +- **POST** `/api/front/gift/recharge/create` +- **请求体**: +```json +{ + "optionId": 1, + "coinAmount": 100, + "price": 10.00 +} +``` +- **响应**: `orderId`, `paymentUrl` + +--- + +## 六、私聊会话 + +### 6.1 获取会话列表 +- **GET** `/api/front/conversations` +- **响应**: `id`, `title`, `lastMessage`, `timeText`, `unreadCount`, `avatarUrl`, `otherUserId` + +### 6.2 搜索会话 +- **GET** `/api/front/conversations/search` +- **参数**: `keyword` + +### 6.3 获取/创建会话 +- **POST** `/api/front/conversations/with/{otherUserId}` +- **响应**: `conversationId` + +### 6.4 标记会话已读 +- **POST** `/api/front/conversations/{id}/read` + +### 6.5 删除会话 +- **DELETE** `/api/front/conversations/{id}` + +### 6.6 获取消息列表 +- **GET** `/api/front/conversations/{id}/messages` +- **参数**: `page`, `pageSize` +- **响应**: `messageId`, `userId`, `username`, `avatarUrl`, `message`, `timestamp`, `status` + +### 6.7 发送私信 +- **POST** `/api/front/conversations/{id}/messages` +- **请求体**: +```json +{ + "message": "消息内容", + "messageType": "text" +} +``` + +### 6.8 删除消息 +- **DELETE** `/api/front/conversations/messages/{id}` + +--- + +## 七、好友管理 + +### 7.1 获取好友列表 +- **GET** `/api/front/friends` +- **参数**: `page`, `pageSize` +- **响应**: `id`, `name`, `avatarUrl`, `phone`, `isOnline`, `lastOnlineTime` + +### 7.2 删除好友 +- **DELETE** `/api/front/friends/{friendId}` + +### 7.3 搜索用户 +- **GET** `/api/front/users/search` +- **参数**: `keyword`, `page`, `pageSize` +- **响应**: `id`, `nickname`, `phone`, `avatarUrl`, `friendStatus` (0=未添加, 1=已是好友, 2=已申请) + +### 7.4 发送好友请求 +- **POST** `/api/front/friends/request` +- **请求体**: +```json +{ + "targetUserId": 123, + "message": "请求消息" +} +``` + +### 7.5 获取好友请求列表 +- **GET** `/api/front/friends/requests` +- **参数**: `page`, `pageSize` + +### 7.6 处理好友请求 +- **POST** `/api/front/friends/requests/{requestId}/handle` +- **请求体**: +```json +{ + "accept": true +} +``` + +--- + +## 八、文件上传 + +### 8.1 上传图片 +- **POST** `/api/front/user/upload/image` +- **参数**: + - `multipart` - 图片文件 + - `model` - 模块 (user/product/wechat/news) + - `pid` - 分类ID (7=前台用户头像) +- **响应**: `url`, `fileName`, `type` + +--- + +## 九、在线状态 + +### 9.1 检查用户在线状态 +- **GET** `/api/front/online/status/{userId}` +- **响应**: `userId`, `online`, `lastActiveTime` + +### 9.2 批量检查在线状态 +- **POST** `/api/front/online/status/batch` +- **请求体**: `[userId1, userId2, ...]` +- **响应**: `total`, `onlineCount`, `onlineUsers` + +### 9.3 获取直播间在线人数 +- **GET** `/api/front/online/room/{roomId}/count` +- **响应**: `roomId`, `count` + +### 9.4 获取直播间在线用户列表 +- **GET** `/api/front/online/room/{roomId}/users` +- **响应**: `roomId`, `count`, `users` + +### 9.5 获取WebSocket连接统计 +- **GET** `/api/front/online/stats` +- **响应**: `activeConnections`, `timestamp` + +--- + +## 十、离线消息 + +### 10.1 获取离线消息数量 +- **GET** `/api/front/online/offline/count/{userId}` +- **响应**: `userId`, `count` + +### 10.2 获取离线消息列表 +- **GET** `/api/front/online/offline/messages/{userId}` +- **参数**: `limit` (默认50) +- **响应**: `userId`, `messages`, `count`, `totalCount` + +### 10.3 清除离线消息 +- **DELETE** `/api/front/online/offline/messages/{userId}` + +--- + +## 接口统计 + +| 模块 | 接口数量 | +|------|----------| +| 用户认证 | 4 | +| 用户信息 | 2 | +| 直播间 | 6 | +| 直播弹幕 | 2 | +| 礼物打赏 | 5 | +| 私聊会话 | 8 | +| 好友管理 | 6 | +| 文件上传 | 1 | +| 在线状态 | 5 | +| 离线消息 | 3 | +| **总计** | **42** | diff --git a/工作日志-接口对接.md b/工作日志-接口对接.md new file mode 100644 index 00000000..ec85bae6 --- /dev/null +++ b/工作日志-接口对接.md @@ -0,0 +1,113 @@ +# 工作日志 - 前后端接口对接 + +> 日期: 2024年12月26日 + +--- + +## 一、完成的工作 + +### 1. Android端接口扩展 + +将 `ApiService.java` 从原来的 6 个接口扩展到 42 个接口,覆盖以下模块: + +- 用户认证(4个) +- 用户信息(2个) +- 直播间(6个) +- 直播弹幕(2个) +- 礼物打赏(5个) +- 私聊会话(8个) +- 好友管理(6个) +- 文件上传(1个) +- 在线状态(5个) +- 离线消息(3个) + +### 2. 新增数据模型类(17个) + +| 文件名 | 说明 | +|--------|------| +| UserInfoResponse.java | 用户信息响应 | +| UserEditRequest.java | 修改用户资料请求 | +| GiftResponse.java | 礼物信息 | +| UserBalanceResponse.java | 用户余额 | +| SendGiftRequest.java | 赠送礼物请求 | +| SendGiftResponse.java | 赠送礼物响应 | +| RechargeOptionResponse.java | 充值选项 | +| CreateRechargeRequest.java | 创建充值订单请求 | +| CreateRechargeResponse.java | 创建充值订单响应 | +| ConversationResponse.java | 会话信息 | +| PrivateMessageResponse.java | 私信消息 | +| SendMessageRequest.java | 发送消息请求 | +| FriendResponse.java | 好友信息 | +| FriendRequestResponse.java | 好友请求 | +| SearchUserResponse.java | 搜索用户结果 | +| PageResponse.java | 分页响应 | +| FileUploadResponse.java | 文件上传响应 | + +### 3. 字段匹配修复 + +检查并修复了 Android 端数据模型与后端返回字段的匹配问题: + +| 文件 | 修改内容 | +|------|----------| +| ConversationResponse | 字段改为 id, title, lastMessage, timeText, unreadCount, muted, avatarUrl, otherUserId | +| PrivateMessageResponse | 字段改为 messageId, userId, username, avatarUrl, message, timestamp, status, isSystemMessage | +| SendMessageRequest | 字段改为 message, messageType | +| CreateRechargeRequest | 字段改为 optionId, coinAmount, price | +| UserInfoResponse | 匹配后端 UserCenterResponse | +| UserEditRequest | 简化为 nickname, avatar | + +### 4. Git配置修改 + +修改 `.gitignore`,注释掉 `**/*.jks` 和 `**/*.keystore`,允许签名文件上传到公司仓库。 + +### 5. 文档编写 + +- `zhibo/android-app/接口文档.md` - 完整的接口说明文档 +- `zhibo/接口对接状态.md` - 三端接口对接状态表格 + +--- + +## 二、接口对接状态 + +| 端 | 已打通 | 未打通 | 完成度 | +|----|--------|--------|--------| +| Android ↔ 后端 | 42 | 0 | 100% | +| Vue Admin ↔ 后端 | 20+ | 0 | 100% | + +--- + +## 三、待完成事项 + +1. 支付SDK集成(微信/支付宝) + +--- + +## 四、修改的文件清单 + +``` +zhibo/ +├── .gitignore # 修改:注释jks规则 +├── 接口对接状态.md # 新增 +├── 工作日志-接口对接.md # 新增 +└── android-app/ + ├── 接口文档.md # 新增 + └── app/src/main/java/.../net/ + ├── ApiService.java # 修改:扩展接口 + ├── ConversationResponse.java # 修改:字段匹配 + ├── PrivateMessageResponse.java # 修改:字段匹配 + ├── SendMessageRequest.java # 修改:字段匹配 + ├── CreateRechargeRequest.java # 修改:字段匹配 + ├── UserInfoResponse.java # 修改:字段匹配 + ├── UserEditRequest.java # 修改:字段匹配 + ├── GiftResponse.java # 新增 + ├── UserBalanceResponse.java # 新增 + ├── SendGiftRequest.java # 新增 + ├── SendGiftResponse.java # 新增 + ├── RechargeOptionResponse.java # 新增 + ├── CreateRechargeResponse.java # 新增 + ├── FriendResponse.java # 新增 + ├── FriendRequestResponse.java # 新增 + ├── SearchUserResponse.java # 新增 + ├── PageResponse.java # 新增 + └── FileUploadResponse.java # 新增 +``` diff --git a/接口对接状态.md b/接口对接状态.md new file mode 100644 index 00000000..bc8efd7c --- /dev/null +++ b/接口对接状态.md @@ -0,0 +1,132 @@ +# 接口对接状态总览 + +> 更新时间: 2024年 + +## 端口配置 + +| 服务 | 端口 | 说明 | +|------|------|------| +| crmeb-front | 8081 | Android/H5 API | +| crmeb-admin | 30001 | Vue Admin API | +| Vue Admin | 9527 | 管理后台前端 | +| SRS RTMP | 25002 | 推流端口 | +| SRS HTTP | 25003 | 播放端口 | + +--- + +## 一、Android端 ↔ 后端 (crmeb-front) + +### ✅ 已打通 (42个) + +| 模块 | 接口 | Android | 后端 | 状态 | +|------|------|---------|------|------| +| **用户认证** | 登录 | ✅ | ✅ | ✅ 已打通 | +| | 注册 | ✅ | ✅ | ✅ 已打通 | +| | 发送验证码 | ✅ | ✅ | ✅ 已打通 | +| | 退出登录 | ✅ | ✅ | ✅ 已打通 | +| **用户信息** | 获取用户信息 | ✅ | ✅ | ✅ 已打通 | +| | 修改用户资料 | ✅ | ✅ | ✅ 已打通 | +| **直播间** | 获取直播间列表 | ✅ | ✅ | ✅ 已打通 | +| | 创建直播间 | ✅ | ✅ | ✅ 已打通 | +| | 获取直播间详情 | ✅ | ✅ | ✅ 已打通 | +| | 删除直播间 | ✅ | ✅ | ✅ 已打通 | +| | 获取观看人数 | ✅ | ✅ | ✅ 已打通 | +| | 关注主播 | ✅ | ✅ | ✅ 已打通 | +| **直播弹幕** | 获取弹幕消息 | ✅ | ✅ | ✅ 已打通 | +| | 发送弹幕 | ✅ | ✅ | ✅ 已打通 | +| **礼物打赏** | 获取礼物列表 | ✅ | ✅ | ✅ 已打通 | +| | 获取用户余额 | ✅ | ✅ | ✅ 已打通 | +| | 赠送礼物 | ✅ | ✅ | ✅ 已打通 | +| | 获取充值选项 | ✅ | ✅ | ✅ 已打通 | +| | 创建充值订单 | ✅ | ✅ | ✅ 已打通 | +| **私聊会话** | 获取会话列表 | ✅ | ✅ | ✅ 已打通 | +| | 搜索会话 | ✅ | ✅ | ✅ 已打通 | +| | 获取/创建会话 | ✅ | ✅ | ✅ 已打通 | +| | 标记已读 | ✅ | ✅ | ✅ 已打通 | +| | 删除会话 | ✅ | ✅ | ✅ 已打通 | +| | 获取消息列表 | ✅ | ✅ | ✅ 已打通 | +| | 发送私信 | ✅ | ✅ | ✅ 已打通 | +| | 删除消息 | ✅ | ✅ | ✅ 已打通 | +| **好友管理** | 获取好友列表 | ✅ | ✅ | ✅ 已打通 | +| | 删除好友 | ✅ | ✅ | ✅ 已打通 | +| | 搜索用户 | ✅ | ✅ | ✅ 已打通 | +| | 发送好友请求 | ✅ | ✅ | ✅ 已打通 | +| | 获取好友请求 | ✅ | ✅ | ✅ 已打通 | +| | 处理好友请求 | ✅ | ✅ | ✅ 已打通 | +| **文件上传** | 上传图片 | ✅ | ✅ | ✅ 已打通 | +| **在线状态** | 检查用户在线 | ✅ | ✅ | ✅ 已打通 | +| | 批量检查在线 | ✅ | ✅ | ✅ 已打通 | +| | 直播间在线人数 | ✅ | ✅ | ✅ 已打通 | +| | 直播间在线用户 | ✅ | ✅ | ✅ 已打通 | +| | WebSocket统计 | ✅ | ✅ | ✅ 已打通 | +| **离线消息** | 获取离线消息数量 | ✅ | ✅ | ✅ 已打通 | +| | 获取离线消息 | ✅ | ✅ | ✅ 已打通 | +| | 清除离线消息 | ✅ | ✅ | ✅ 已打通 | + +### ⏳ 未打通 (后端有,Android未定义) + +| 模块 | 接口 | Android | 后端 | 说明 | +|------|------|---------|------|------| +| 无 | - | - | - | 全部已打通 | + +--- + +## 二、Vue Admin前端 ↔ 后端 (crmeb-admin) + +### ✅ 已打通 + +| 模块 | 功能 | Vue Admin | 后端 | 状态 | +|------|------|-----------|------|------| +| **用户管理** | 用户列表 | ✅ | ✅ | ✅ 已打通 | +| | 用户详情 | ✅ | ✅ | ✅ 已打通 | +| | 用户编辑 | ✅ | ✅ | ✅ 已打通 | +| | 用户等级 | ✅ | ✅ | ✅ 已打通 | +| | 用户分组 | ✅ | ✅ | ✅ 已打通 | +| | 用户标签 | ✅ | ✅ | ✅ 已打通 | +| **直播间管理** | 直播间列表 | ✅ | ✅ | ✅ 已打通 | +| | 创建直播间 | ✅ | ✅ | ✅ 已打通 | +| | 开播/停播 | ✅ | ✅ | ✅ 已打通 | +| | 弹幕记录 | ✅ | ✅ | ✅ 已打通 | +| | 房间类型 | ✅ | ✅ | ✅ 已打通 | +| | 房间背景 | ✅ | ✅ | ✅ 已打通 | +| **礼物管理** | 礼物列表 | ✅ | ✅ | ✅ 已打通 | +| | 打赏记录 | ✅ | ✅ | ✅ 已打通 | +| **财务管理** | 充值记录 | ✅ | ✅ | ✅ 已打通 | +| | 提现管理 | ✅ | ✅ | ✅ 已打通 | +| | 账单明细 | ✅ | ✅ | ✅ 已打通 | +| **系统设置** | 管理员 | ✅ | ✅ | ✅ 已打通 | +| | 角色权限 | ✅ | ✅ | ✅ 已打通 | +| | 系统配置 | ✅ | ✅ | ✅ 已打通 | + +--- + +## 三、数据流向 + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Android │────▶│ crmeb-front │────▶│ │ +│ APP │ │ :8081 │ │ │ +└─────────────┘ └─────────────┘ │ │ + │ MySQL │ +┌─────────────┐ ┌─────────────┐ │ 数据库 │ +│ Vue Admin │────▶│ crmeb-admin │────▶│ │ +│ :9527 │ │ :30001 │ │ │ +└─────────────┘ └─────────────┘ └─────────────┘ +``` + +--- + +## 四、统计 + +| 端 | 已打通 | 未打通 | 完成度 | +|----|--------|--------|--------| +| Android ↔ 后端 | 42 | 0 | 100% | +| Vue Admin ↔ 后端 | 20+ | 0 | 100% | + +--- + +## 五、待完成事项 + +1. ~~图片上传接口(用户头像)~~ ✅ 已完成 +2. ~~在线状态接口~~ ✅ 已完成 +3. 支付SDK集成(微信/支付宝)