【Android】ChatRoom App 技术分析

发布于:2025-08-14 ⋅ 阅读:(30) ⋅ 点赞:(0)

网络

OkHttp

配合后端上传或拉取资源

OKHTTP 是 Android 流行的网络请求库,性能好且功能强大,聊天室项目主要使用 OKHTTP 来提交和拉取 JSON 数据

public static final String url = "https://api.example.com/example";

OkHttpClient client = new OkHttpClient();
RequestBody requestBody = RequestBody.create(
		json.toString(), 
    	MediaType.get("application/json; charset=utf-8")
);
Request request = new Request.Builder()
    .url(url)
    .post(requestBody) // .get() 表示拉取JSON
    .build();

Response response = client.newCall(request).execute();
String result = response.body().string();
// 得到JSON数据

WebSocket

建立网络通信连接

简单的实现方法是 OkHttp 结合 WebSocketListener,首先要重写 WebSocketListener 的 4 种重写方法

通常还要加入 start 开始连接,sendMessage 发送消息和 close 主动关闭的方法,这 3 种方法往往接受 OkHttpClient 和 WebSocket 实例

public class MyWebSocketListener extends WebSocketListener {
    
    private Activity activity;
    private OkHttpClient client;
    private WebSocket webSocket;

    public void start(Activity activity, String url) {
        this.activity = activity;
        this.viewModel = viewModel;

        client = new OkHttpClient();
        Request request = new Request.Builder()
                .url(url)
                .build();
        webSocket = client.newWebSocket(request, this);
    }
    
    public void sendMessage(String message) { if(webSocket != null) webSocket.send(message); }
    
    public void close() { if(webSocket != null) webSocket.close(1000, "Closing"); }
    
    @Override
    public void onOpen(WebSocket webSocket, Response response){
        super.onOpen(webSocket, response);
        // 连接成功的回调
    }
    
    @Override
    public void onFailure(WebSocket webSocket, Throwable t, Response response){
        super.onFailure(webSocket, t, response);
        // 连接失败的回调
    }
    
    @Override
    public void onMessage(WebSocket webSocket, String text) {
        // 回调处理接收到的JSON数据
    }
    
    @Override
    public void onClosed(WebSocket webSocket, int code, String reason) {
        super.onClosed(webSocket, code, reason);
        // 连接关闭的回调
    }
}

UI

RecyclerView

消息列表

首先准备数据模型类 Item 和 item 条目的布局文件 item.xml,定义继承自 RecyclerView.Adapter<VH> 的类,重写构造方法,onCreateViewHolder,onBindViewHolder 和 getItemCount,VH 是同样自定义继承自 RecyclerView.ViewHolder 的类,通常可以写为内部类

public class MyRecyclerViewAdapter extends RecyclerView.Adapter<MyRecyclerViewAdapter.MyViewHolder> {
    
    // 用于绑定和管理条目布局控件的ViewHolder内部类
	public static class MyViewHolder extends RecyclerView.ViewHolder {
        TextView textView;
        ImageView imageView;
        
        public MyViewHolder(@NonNull View itemView){
            super(itemView);
            textView = itemView.findViewById(R.id.text_view);
            imageView = itemView.findViewById(R.id.image_view);
        }
    }
    
    private final List<Item> itemList; // 数据模型集合
    
    public MyRecyclerViewAdapter(List<Item> itemList){ this.itemList = itemList; }
    
    // 创建ViewHolder并加载item布局
    @NonNull
    @Override
    public MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
        LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
        return new Holder(layoutInflater.inflate(R.layout.item, parent, false));
    }
    // LayoutInflater 是用于将布局xml文件转换成View对象的工具
    // inflate方法用于实际转换并将View放入指定的布局对象parent内,false表示不立即放入而是等待RecylcerView处理
    
    // 绑定数据到ViewHolder管理的控件
    @Override
    public void onBindViewHolder(@NonNull MyViewHolder holder, int position){
        Item item = itemList.get(position);
        
        // 准备数据
        holder.textView.setText(item.getText());
        holder.imageView.setImageResource(item.getImage());
    }
}
RecyclerView recyclerView = findViewById(R.id.recycler_view);

LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
recyclerView.setLayoutManager(linearLayoutManager);

MyRecyclerViewAdapter adapter = new MyRecyclerViewAdapter(itemList /* 数据源 */);
recyclerView.setAdapter(adapter);

适应性布局

通过重写 getItemViewType 方法获得不同 viewType 标识符以适应性加载不同条目布局

@Override
public int getItemViewType(int position) {
    Item item = itemList.get(position);
    return item.getType();
    // Type 可以是 public static final int 常量
}

@NonNull
@Override
public MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
    LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
    View view;
    if(viewType == TYPE_A) {
        view = layoutInflater.inflate(R.layout.item_A);
    } else if(){
        // ...
    }
    return new MyViewHolder(view);
}

DiffUtil

Android 提供的工具类,用于计算两个列表之间的差异,生成增删改和移动的操作序列,且通知 RecyclerView 更新 UI

DiffUtil 比 notifyDataSetChanged 更高效,只刷新差异的部分

1. DiffUtil.Callback 子类
public class MessageDiffCallback extends DiffUtil.Callback {
    
    private final List<item> oldList;
    private final List<item> newList;
    
    public MessageDiffCallback(List<item> oldList, List<item> newList){
        this.oldList = oldList;
        this.newList = newList;
    }
    
    @Override
    public int getOldListSize(){ return oldList.size(); }
    
    @Override
    public int getNewListSize(){ return newList.size(); }
    
    @Override
    public boolean areItemsTheSame(int oldItemPosition, int newItemPosition){
        return oldList.get(oldItemPosition).getId() == 
               newList.get(newItemPosition).getId();
    } // 根据特征值(这里是id)判断是否为同一条目
    
    @Override
    public boolean areContentsTheSame(int oldItemPosition. int newItemPosition){
        return oldList.get(oldItemPosition).equals(newList.get(newItemPosition));
    }
}
2. 计算差异 更新 Adapter
List<ChatMessageItem> oldList = adapter.getMessages();
List<ChatMessageItem> newList = viewModel.getMessages().getValue();

DiffUtil.Callback callback = new MessageDiffCallback(oldList, newList);
DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(callback);

adapter.setMessages(newList); // 先更新Adapter内部数据
diffResult.dispatchUpdatesTo(adapter); // 更新UI的核心语句

ViewPager2 & BottomNavigationView

滑动切换页面配合底部导航栏

首先在主布局 xml 文件定义 ViewPager2 和 BottomBavigationView,创建 menu 文件并放入若干 item

定义继承 FragmentStateAdapter 类的 ViewPagerAdapter

public class ViewPagerAdapter extends FragmentStateAdapter {
    
    public ViewPagerAdapter(FragmentActivity fragmentActivity){ super(fragmentActivity) }
    
    @NonNull
    @Override
    public Fragment createFragement(int position){
        switch (position) {
            case 0:
                return new FragmentA();
            case 1:
                return new FragmentB();
            // ...
            default:
                return new FragmentDefault();
        }
    }
    
    @Override
    pubilc int getItemCount(){ return 2; /* position(max) + 1 */ }
}

在主页面设置 ViewPager2 和 BottomBavigationView 的逻辑

public class MainActivity extends AppCompatActivity {

    private ViewPager2 viewPager;
    private BottomNavigationView bottomNavigationView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        viewPager = findViewById(R.id.viewPager);
        bottomNavigationView = findViewById(R.id.bottomNavigationView);

        // 设置 Adapter
        viewPager.setAdapter(new ViewPagerAdapter(this));

        // BottomNavigationView 点击切换页面
        bottomNavigationView.setOnItemSelectedListener(item -> {
            switch (item.getItemId()) {
                case R.id.nav_home:
                    viewPager.setCurrentItem(0, false);
                    return true;
                case R.id.nav_dashboard:
                    viewPager.setCurrentItem(1, false);
                    return true;
                case R.id.nav_notifications:
                    viewPager.setCurrentItem(2, false);
                    return true;
            }
            return false;
        });

        // ViewPager2 页面切换时更新 BottomNavigationView 选中状态
        viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
            @Override
            public void onPageSelected(int position) {
                switch (position) {
                    case 0:
                        bottomNavigationView.setSelectedItemId(R.id.nav_home);
                        break;
                    case 1:
                        bottomNavigationView.setSelectedItemId(R.id.nav_dashboard);
                        break;
                    case 2:
                        bottomNavigationView.setSelectedItemId(R.id.nav_notifications);
                        break;
                }
            }
        });
    }
}

重要方法有:

底部导航栏的 setOnItemSelectedListenersetSelectedItemId,前者用于监听导航栏菜单项点击逻辑,返回 MenuItem 对象;后者设置导航栏当前 item 更新为正常的选中状态

ViewPager2 的 registerOnPageChangeCallbacksetCurrentItem,前者用于监听用户滑动或调用 setCurrentItem 的逻辑;后者直接切换到指定索引页面

真正发挥切换页面作用的是 setCurrentItem

ViewBinding

使用方式

  1. 在 app build.gradle 的 android 配置块加入

    buildFeatures {
        viewBinding true
    }
    
  2. ViewBinding 会根据每个 xml 布局文件生成对应的绑定类,生成规则为去掉下划线且将首字母大写 + Binding,例如 activity_main.xml 的绑定类为 ActivityMainBinding

  3. ViewBinding 绑定类拥有 2 种实例化方式,分别对应活动布局和子布局(如 Fragment 或 View)

    // 以 ActivityMainBinding 为例
    public class MainActivity extends AppCompatActivity {
        private ActivityMainBinding binding;
        
        @Override
        protected void onCreate(Bundle savedInstanceState){
            super.onCreate(savedInstanceState);
    		
            binding = ActivityMainBinding.inflate(getLayoutInflater());
            /* 	完成的工作有
            	1. 实例化绑定类对象
            	2. 加载 activity_main.xml 布局文件
            	3. 创建布局里所有View的对象
            	所有嵌套的View都能被创建
            	4. 将View对象与绑定类里的成员变量关联起来
             */
            setContentView(binding.getRoot());
            // 告诉Activity使用绑定类的根布局作为界面根布局
        }
    }
    
    // 以 FragmentMainBinding 为例,构造方法和其他重写方法略
    public class MainFragment extends Fragment {
        private ActivityMainBinding binding;
        
        @Override
        public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,Bundle savedInstanceState) {
            binding = FragmentInformationBinding.inflate(inflater, container, false);
            // 与单参构造方法类似,因为碎片等布局在活动内部,所以要传入父容器实例
            return binding.getRoot();
        }
        
        @Override
        public void onDestroyView() {
            super.onDestroyView();
            binding = null;
        }
    }
    
  4. 完成初始化操作后,即可引用绑定类实例的成员变量来直接操作控件

LayoutInflater

用于将 xml 布局加载成 View 对象

1. setContentView

Activity 的 setContentView 在底层会调用 LayoutInflater 加载 xml 文件成 View 对象

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    // 几乎等同于
    View view = getLayoutInflater().inflater(R.layout.activity_main, null);
    setContentView(view);

2. ViewBinding 初始化

ViewBinding 使用 LayoutInflater 来加载布局且返回绑定类的实例

binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());

3. RecyclerView Adapter onCreateViewHolder

@Override
public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType){
    View view = LayoutInflater.from(parent.getContent()).inflate(R.layout.item_layout, parent, false);
    return new MyViewHolder(view);
} // item 需要随用户滑动动态加载显示

4. 动态引入布局

LinearLayout container = findViewById(R.id.main);
View butotnView = getLayoutInflater().inflate(R.layout.button_layout, container, false);
container.addView(buttonView);

希望动态加载的控件不一定是额外的 xml layout 文件,只要是存在的 ViewGroup 父容器即可,布局文件中提前写空的 layout 可以明确的确定控件位置,方便做 UI 排版

数据

单例模式

设置用户自己的个人信息类

public class Instance {
    private static final Instance instance = new Instance();
    
    private Data data;
    
    private Instance(){} 
    // 私有化构造方法,保证全局只有instance唯一实例
    
    public static Instance getInstance(){ return instance; }
    
    public Data getData(){ return data; }
    
    public void setData(Data data){ this.data = data; }
} 
// 饿汉式,在类初始化后就加载实例,而不是在getInstance方法被调用时

ViewModel

存储消息集合

ViewModel 类似单例模式,不随活动碎片销毁而回收,但在绑定宿主调用 finish 方法或不再加入返回栈后回收

LiveData 是生命周期感知型数据容器,可以让实例 Observe 观察 LiveData 的数据变化,执行更新逻辑

MutableLiveData 继承自 LiveData,是可写的,提供了主线程进行的 setValue 方法和回调的 postValue 方法

一个好的 ViewModel 继承类应该有私有的 MutableLiveData,提供一系列操作容器的方法,最终调用 setValue 或 postValue 方法来回调 Observe 的更新逻辑

其他观察 ViewModel 的实例应该只有 LiveData 只读容器的引用

SharedPreferences

登录缓存

SharedPreferences 用于保存一些简单的键值对数据(比如设置、用户偏好、登录状态等)

// Activity/Fragment 中获取 SharedPreferences
String document_name = "";
SharedPreferences sp = getSharedPreferences(document_name, MODE_PRIVATE);
// MODE_PRIVATE 表示文件私有
// 全局获取 通过上下文实例 Context 调用 getSharedPreferences 获得
    
SharedPreferences.Editor editor = sp.edit();

editor.putString("username", "Young");
editor.putInt("age", 20);
editor.putBoolean("isLogin", true);

editor.apply();  
// apply 异步提交保存 commit 同步提交保存 返回 boolean 表示是否成功

// 删除调用 remove 方法 参数传递 put 的键名 最后也要保存

String name = sp.getString("username", "defaultName");
// 读取数据不需要 Editor 实例 第二参数是键不存在时的默认返回值

SQLiteOpenHelper & Cursor

本地存储消息

继承自 SQLiteOpenHelper 的自定义类可以用于创建数据库和提供可读写的数据库对象 SQLiteDatabase

基本使用

public class DatabaseHelper extends SQLiteOpenHelper {

    private static final String DB_NAME = "database.db";
    private static final int DB_VERSION = 1;

    public MyDatabaseHelper(Context context) {
        super(context, DB_NAME, null, DB_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        String createTable = "CREATE TABLE chair (" +
                "id INTEGER PRIMARY KEY AUTOINCREMENT, " +
                "name TEXT, " +
                "age INTEGER)";
        db.execSQL(createTable);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        db.execSQL("DROP TABLE IF EXISTS chair");
		// DROP TABLE IF EXISTS chair 库中存在 chair 表则删除 否则什么都不做
        onCreate(db);
    }
}

CURD

DatabaseHelper dbHelper = new DatabaseHelper(context);
SQLiteDatabase db = dbHelper.getWritableDatabase(); // 获取可写流

// 增
ContentValues values = new ContentValues();
values.put(key1, value1);
values.put(key2, value2);
db.insert(table, null, values);

// 改
ContentValues updateValues = new ContentValues();
updateValues.put(key, value);
db.update(table, updateValues, "name=?", new String[]{"4Forsee"});

/* 
int update(
    String table,           // 表名
    ContentValues values,   // 要更新的列及新值
    String whereClause,     // WHERE 条件(可以用 ? 占位)
    String[] whereArgs      // 占位符对应的值
)
*/

// 删
db.delete(table, "name=?", new String[]{"4Forsee"});
Cursor 查

Cursor 是 SQLite 查询结果的游标对象,指向一行数据,可以按列读取数据

SQLiteDatabase db = dbHelper.getReadableDatabase();
Cursor cursor = db.rawQuery("SELECT * FROM chair WHERE age > ?", new String[]{"24"});
// rawQuery 原生使用SQL语句查询 query方法参数繁杂

if (cursor.moveToFirst()) { // 移到第一行
    do {
        String name = cursor.getString(cursor.getColumnIndexOrThrow("name"));
        Log.d("TAG", "name=" + name);
    } while (cursor.moveToNext());
}
// 

cursor.close();
db.close();

活动间通信

ActivityResult

用于打开相册

Activity1 期望启动 Activity2 且回调 Activity2 的返回值在 < 30 API 前要配对使用 startActivityForResult(Intent intent, int requestCode)onActivityResult(int requestCode, int resultCode, Intent data) ,前者调用后者重写,requestCode 是唯一整数,用于在回调区分来源

当启动活动 Activity2 调用 setResult(int resultCode, Intent intent) 且 finish 结束后,回调 Activity1 的 onActivityResult 方法,在方法中根据不同的 requestCode 请求码和 resultCode 返回码设计具体逻辑

这样写有 requestCode,resultCode 和 Intent 的解析码都要处理,代码耦合且不直观,所以有 ActivityResult 的出现,内部管理 requestCode,使用方法如下:

  1. private ActivityResultLauncher<Intent> launcher; 在 Activity 中设置 ActivityResultLauncher 成员变量

  2. 在 Activity 的 onCreate 方法中注册回调

    launcher = registerForActivityResult(
            new ActivityResultContracts.StartActivityForResult(),
            result -> {
                if (result.getResultCode() == RESULT_OK) {
                    Intent data = result.getData();
                    if (data != null) {
                        String value = data.getStringExtra("result_key");
                        // 处理返回的数据
                    }
                }
            }
        );
    
  3. Intent intent = new Intent(this, Activity2.class);
    launcher.launch(intent);
    
  4. Activity2 的回调逻辑和原来一样,都是调用 setResult 方法且结束后回调

注册回调结构

launcher = 
    registerForActivityResult(ActivityResultContract<I, O> Contract, ActivityResultCallback<O> Callback);

ActivityResultContract 是安卓已经写好的任务合同,定义了发送接收数据的类型和任务执行的具体细节

ActivityResultCallback 是回调逻辑,result 的数据类型相当于 Contract 的接收数据类型

未完成

  • 点击按钮显示聊天室所有成员
  • 后端代码迁移到新服务器
  • 尝试开通SMTP做邮箱验证码登录和找回密码
  • 让下线的用户也能接收到消息
  • 发送多媒体文件
  • 实时更新用户信息
  • 长按消息出现删除按钮

服务器搭建

  1. 在阿里云平台的 轻量应用服务器 或 云服务器 ECS 购买服务器,也可以尝试拿到试用服务器,通常是 1 个月

  2. 创建系统时选择 Linux Ubuntu 且安装 Docker,不同的 Linux 发行版本有不同的软件包管理器(yum 或 apt)

  3. 创建成功后直接进入或设置密码进入服务器内部,在命令行输入下列内容:

    sudo apt update
    
    # 安装 Docker
    sudo apt install -y docker.io
    sudo systemctl enable --now docker
    sudo docker --version
    
    # 安装 Node.js
    sudo apt install -y nodejs npm
    
    # 安装 PM2
    sudo npm install -g pm2
    pm2 --version
    
    # 安装 Nano 编辑器
    sudo apt install -y nano
    nano --version
    
    # 拉取v8.0的 MySQL 镜像
    docker pull mysql:8.0
    
    # 运行 MySQL 容器
    docker run --name 容器名称 -e MYSQL_ROOT_PASSWORD=密码 -p 3306:3306 -d mysql:8.0
    
  4. 成功执行后,服务器就拥有简易的后端环境,可以继续安装 express,ws,mysql2,multer 等模块

表和存储路径

+----------+--------------+------+-----+---------+----------------+
| Field    | Type         | Null | Key | Default | Extra          |
+----------+--------------+------+-----+---------+----------------+
| id       | int          | NO   | PRI | NULL    | auto_increment |
| username | varchar(50)  | NO   | UNI | NULL    |                |
| password | varchar(100) | NO   |     | NULL    |                |
+----------+--------------+------+-----+---------+----------------+ (users)

+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int          | NO   | PRI | NULL    | auto_increment |
| avatar | varchar(255) | YES  |     | NULL    |                |
| nickname   | varchar(50)  | YES  |     |         |                |
| bio        | text         | YES  |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+ (information)

服务器头像存储路径 /home/admin/public/avatar/

默认头像 0.png

本地头像存储路径 /data/data/com.example.chatroom/files/avatar

Nodejs 后端代码

const express = require('express');
const mysql = require('mysql2/promise');
const multer = require('multer');
const fs = require('fs');
const path = require('path');

const http = require('http');
const { WebSocketServer, WebSocket } = require('ws');

const app = express();
app.use(express.json());

const server = http.createServer(app);
const wss = new WebSocketServer({ server });

// MySQL 配置
const dbConfig = {
    host: 'localhost',
    port: 3306,
    user: 'root',
    password: '123456',
    database: 'chatdb'
};

// 头像存储目录
const avatarDir = '/home/admin/public/avatar/';
if (!fs.existsSync(avatarDir)) {
    fs.mkdirSync(avatarDir, { recursive: true });
}

// Multer 配置:接收 multipart/form-data 的 avatar 文件
const storage = multer.diskStorage({
    destination: (req, file, cb) => cb(null, avatarDir),
    filename: (req, file, cb) => {
        const ext = path.extname(file.originalname);
        const name = Date.now() + '-' + Math.round(Math.random() * 1e9) + ext;
        cb(null, name);
    }
});
const upload = multer({ storage });

const getActiveClientCount = () => {
    let count = 0;
    wss.clients.forEach(client => {
        if (client.readyState === WebSocket.OPEN) {
            count++;
        }
    });
    return count;
};

wss.on('connection', (ws) => {
    console.log('有客户端连接,当前客户端数量:', getActiveClientCount());

    ws.on('message', (msg) => {
        let obj;
        try {
            obj = JSON.parse(msg);
        } catch (e) {
            return;
        }

        // 广播给所有客户端
        wss.clients.forEach(client => {
            console.log('发送成功');
            if (client.readyState === ws.OPEN) {
                client.send(JSON.stringify({
                    ...obj,
                    isSelf: client === ws ? 1 : 0
                }));
            }
        });
    });

    ws.on('close', (code, reason) => {
        console.log('客户端断开连接,当前客户端数量:', getActiveClientCount());
    });

});


/**
 * 上传头像并更新数据库
 * POST /upload
 * fields:
 *   - id: 用户 ID (text)
 *   - avatar: 头像文件 (file)
 */
app.post('/upload', upload.single('avatar'), async (req, res) => {
    const userId = req.body.id;
    if (!userId) {
        return res.status(400).json({ success: false, message: '缺少用户 ID' });
    }
    if (!req.file) {
        return res.status(400).json({ success: false, message: '没有上传文件' });
    }

    const absolutePath = path.join(avatarDir, req.file.filename);

    let connection;
    try {
        connection = await mysql.createConnection(dbConfig);
        const [result] = await connection.execute(
            'UPDATE information SET avatar = ? WHERE id = ?',
            [absolutePath, userId]
        );
        if (result.affectedRows === 0) {
            return res.json({ success: false, message: '未找到该用户信息,更新失败' });
        }
        return res.json({
            success: true,
            message: '头像上传并更新成功',
            avatarPath: absolutePath
        });
    } catch (err) {
        console.error(err);
        return res.status(500).json({ success: false, message: '服务器内部错误' });
    } finally {
        if (connection) await connection.end();
    }
});

/**
 * 更新用户个人信息(不含头像)
 * POST /update
 * JSON body:
 *   { id, nickname, bio }
 */
app.post('/update', async (req, res) => {
    const { id, nickname, bio } = req.body;
    if (!id || nickname === undefined || bio === undefined) {
        return res.status(400).json({ success: false, message: '参数不完整' });
    }

    let connection;
    try {
        connection = await mysql.createConnection(dbConfig);
        const [result] = await connection.execute(
            'UPDATE information SET nickname = ?, bio = ? WHERE id = ?',
            [nickname, bio, id]
        );
        if (result.affectedRows === 0) {
            return res.json({ success: false, message: '未找到该用户信息,更新失败' });
        }
        return res.json({ success: true, message: '信息更新成功' });
    } catch (err) {
        console.error(err);
        return res.status(500).json({ success: false, message: '服务器错误' });
    } finally {
        if (connection) await connection.end();
    }
});

/**
 * 登录接口
 * POST /login
 * JSON body:
 *   { username, password }
 */
app.post('/login', async (req, res) => {
    const { username, password } = req.body;
    if (!username || !password) {
        return res.status(400).json({ success: false, message: '用户名或密码不能为空' });
    }

    let connection;
    try {
        connection = await mysql.createConnection(dbConfig);
        const [rows] = await connection.execute(
            'SELECT * FROM users WHERE username = ?',
            [username]
        );

        if (rows.length === 0) {
            const [insertResult] = await connection.execute(
                'INSERT INTO users (username, password) VALUES (?, ?)',
                [username, password]
            );
            await connection.execute(
                'INSERT INTO information (id, avatar, nickname, bio) VALUES (?, ?, ?, ?)',
                [insertResult.insertId, '/home/admin/public/avatar/0.png', username, '']
            );
            return res.json({
                success: true,
                message: '新用户已创建并登录',
                user: {
                    id: insertResult.insertId,
                    username,
                    avatar: '/home/admin/public/avatar/0.png',
                    nickname: username,
                    bio: ''
                }
            });
        } else {
            if (rows[0].password !== password) {
                return res.status(401).json({ success: false, message: '密码错误' });
            }
            const userId = rows[0].id;
            const [infoRows] = await connection.execute(
                'SELECT avatar, nickname, bio FROM information WHERE id = ?',
                [userId]
            );
            return res.json({
                success: true,
                message: '登录成功',
                user: {
                    id: userId,
                    username,
                    avatar: infoRows[0]?.avatar || '',
                    nickname: infoRows[0]?.nickname || '',
                    bio: infoRows[0]?.bio || ''
                }
            });
        }
    } catch (err) {
        console.error(err);
        return res.status(500).json({ success: false, message: '服务器错误' });
    } finally {
        if (connection) await connection.end();
    }
});

/**
 * 获取所有头像
 * GET /avatars
 * 返回:{ success: true, data: [avatar1, avatar2, ...] }
 */
app.get('/avatars', async (req, res) => {
    try {
        const connection = await mysql.createConnection(dbConfig);
        const [results] = await connection.execute('SELECT avatar FROM information');
        await connection.end();

        const avatars = results.map(row => row.avatar);
        res.json({ success: true, data: avatars });
    } catch (err) {
        return res.status(500).json({ success: false, message: '服务器错误' });
    }
});

server.listen(3000, '0.0.0.0', () => {
    console.log('服务器启动,监听端口 3000');
});

网站公告

今日签到

点亮在社区的每一天
去签到