前言

近期在博客社交类项目中,完成了消息中心 - 可能感兴趣的用户推荐模块开发。该功能根据用户browse_rate(浏览指数)做智能筛选、排序,同时实现头像跳转个人主页、关注 / 取消关注全套交互。 技术栈:Django + MySQL + UniApp,下面完整记录从数据表设计、后端接口开发、前端页面实现、问题踩坑全流程,适合前后端开发者参考学习。

一、功能需求说明

  1. 数据来源:读取项目 user 用户表;
  2. 筛选规则:排除当前登录用户,不把自己推荐给自己;
  3. 排序规则:根据 browse_rate 浏览指数降序展示(数值越高排名越靠前);
  4. 数量限制:仅展示 4~5 个推荐用户,保证页面布局整洁;
  5. 交互要求
    • 前端摒弃静态写死数据,动态接口渲染
    • 点击头像跳转对方个人主页;
    • 个人主页支持关注 / 取消关注,自动识别关注状态;
  6. 容错处理:无数据、接口异常、参数错误等场景做兜底提示。

二、数据库结构

1. 用户表 user

browse_rate 字段为字符串类型,格式为 数字,数字,数字(多维度浏览指数),这也是本次排序的核心字段。

sql

CREATE TABLE `user` (
  `user_id` int NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `username` varchar(50) NOT NULL COMMENT '用户名',
  `password` varchar(100) NOT NULL COMMENT '密码',
  `phone` varchar(255) NULL DEFAULT NULL COMMENT '手机号',
  `subscribe` int NOT DEFAULT 0 COMMENT '关注人数',
  `fan` int NOT DEFAULT 0 COMMENT '粉丝人数',
  `like_and_collect` int NOT DEFAULT 0 COMMENT '获赞收藏数',
  `avatar_url` varchar(255) NULL DEFAULT NULL COMMENT '头像地址',
  `browse_rate` varchar(255) DEFAULT "0" COMMENT '浏览指数(三值字符串)',
  PRIMARY KEY (`user_id`)
) ENGINE = InnoDB DEFAULT CHARSET=utf8mb4;

2. 关注关系表 user_follow

用于存储用户关注关系,实现关注 / 取关、状态查询功能,设置联合唯一约束,防止重复关注。

sql

CREATE TABLE `user_follow` (
  `id` int NOT NULL AUTO_INCREMENT,
  `self_id` int NOT NULL COMMENT '关注人ID',
  `target_id` int NOT NULL COMMENT '被关注人ID',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '关注时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uni_self_target` (`self_id`,`target_id`)
) ENGINE = InnoDB DEFAULT CHARSET=utf8mb4;

三、后端实现(Django)

3.1 模型映射 models.py

user 应用模型

python

运行

from django.db import models

class User(models.Model):
    user_id = models.AutoField(primary_key=True, verbose_name="用户ID")
    username = models.CharField(max_length=50, verbose_name="用户名")
    password = models.CharField(max_length=100, verbose_name="密码")
    phone = models.CharField(max_length=20, null=True, blank=True, verbose_name="手机号")
    subscribe = models.IntegerField(default=0, verbose_name="关注人数")
    fan = models.IntegerField(default=0, verbose_name="粉丝人数")
    like_and_collect = models.IntegerField(default=0, verbose_name="获赞与收藏总数")
    avatar_url = models.CharField(max_length=255, null=True, blank=True, verbose_name="头像地址")
    # 三值字符串:兼容 单数字 / 数字,数字,数字 两种格式
    browse_rate = models.CharField(max_length=255, default="0", verbose_name="浏览指数")

    class Meta:
        db_table = "user"
interaction 交互应用模型(关注表)

python

运行

from django.db import models

class UserFollow(models.Model):
    self_id = models.IntegerField(verbose_name="关注人ID")
    target_id = models.IntegerField(verbose_name="被关注人ID")
    create_time = models.DateTimeField(auto_now_add=True, verbose_name="关注时间")

    class Meta:
        db_table = "user_follow"
        unique_together = ("self_id", "target_id")  # 联合唯一,禁止重复关注

执行数据迁移:

bash

运行

python manage.py makemigrations
python manage.py migrate

3.2 核心接口开发(interaction/views.py)

所有交互相关接口统一放在 interaction 应用,一共开发 4 个接口

  1. 推荐用户接口(核心排序)
  2. 获取对方用户信息接口
  3. 查询关注状态接口
  4. 关注 / 取消关注操作接口

python

运行

import json
from django.views import View
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from user.models import User
from .models import User

# 1. 可能感兴趣的用户推荐接口(按browse_rate排序)
@method_decorator(csrf_exempt, name='dispatch')
class RecommendUserView(View):
    def post(self, request):
        try:
            data = json.loads(request.body)
            current_user_id = data.get("user_id")
            if not current_user_id:
                return JsonResponse({"code": 400, "msg": "用户ID不能为空", "data": []})

            # 排除当前登录用户
            user_query = User.objects.exclude(user_id=current_user_id)
            temp_data = []

            # 解析browse_rate三值字符串,取第一个值作为排序依据
            for user in user_query:
                rate_str = user.browse_rate if user.browse_rate else "0"
                rate_arr = rate_str.split(",")
                try:
                    sort_score = int(rate_arr[0])
                except (ValueError, IndexError):
                    sort_score = 0
                temp_data.append({"obj": user, "score": sort_score})

            # 按浏览指数 降序排序
            temp_data.sort(key=lambda x: x["score"], reverse=True)
            # 限制只返回前5条(4-5人需求)
            temp_data = temp_data[:5]

            # 组装返回前端的数据
            result = []
            for item in temp_data:
                u = item["obj"]
                result.append({
                    "user_id": u.user_id,
                    "name": u.username,
                    "avatar": u.avatar_url if u.avatar_url else "/static/img/avatar.png",
                    "desc": "园艺爱好者"
                })
            return JsonResponse({"code": 200, "msg": "获取成功", "data": result})
        except Exception as e:
            print("推荐接口异常:", str(e))
            return JsonResponse({"code": 500, "msg": "服务器异常", "data": []})

# 2. 获取用户详情接口
@method_decorator(csrf_exempt, name='dispatch')
class GetUserInfoView(View):
    def post(self, request):
        try:
            data = json.loads(request.body)
            user_id = data.get("user_id")
            user = User.objects.filter(user_id=user_id).first()
            if not user:
                return JsonResponse({"code": 404, "msg": "用户不存在", "data": {}})
            res = {
                "user_id": user.user_id,
                "username": user.username,
                "avatar": user.avatar_url,
                "browse_rate": user.browse_rate
            }
            return JsonResponse({"code": 200, "msg": "成功", "data": res})
        except Exception as e:
            return JsonResponse({"code": 500, "msg": str(e)})

# 3. 查询是否已关注
@method_decorator(csrf_exempt, name='dispatch')
class CheckFollowView(View):
    def post(self, request):
        try:
            data = json.loads(request.body)
            self_id = data.get("self_id")
            target_id = data.get("target_id")
            # 判断是否存在关注记录
            is_follow = UserFollow.objects.filter(self_id=self_id, target_id=target_id).exists()
            return JsonResponse({"code": 200, "is_follow": is_follow})
        except Exception as e:
            return JsonResponse({"code": 500, "msg": str(e)})

# 4. 关注 / 取消关注 操作
@method_decorator(csrf_exempt, name='dispatch')
class FollowActionView(View):
    def post(self, request):
        try:
            data = json.loads(request.body)
            self_id = data.get("self_id")
            target_id = data.get("target_id")
            action = data.get("action")  # 1=关注  2=取消关注

            if str(self_id) == str(target_id):
                return JsonResponse({"code": 400, "msg": "不能关注自己"})

            if action == 1:
                # 新增关注
                UserFollow.objects.get_or_create(self_id=self_id, target_id=target_id)
                return JsonResponse({"code": 200, "msg": "关注成功"})
            elif action == 2:
                # 取消关注
                UserFollow.objects.filter(self_id=self_id, target_id).delete()
                return JsonResponse({"code": 200, "msg": "取消关注成功"})
            else:
                return JsonResponse({"code": 400, "msg": "操作类型错误"})
        except Exception as e:
            return JsonResponse({"code": 500, "msg": str(e)})

3.3 路由配置 interaction/urls.py

python

运行

from django.urls import path
from . import views

urlpatterns = [
    path("recommend_user", views.RecommendUserView.as_view()),
    path("get_user_info", views.GetUserInfoView.as_view()),
    path("check_follow", views.CheckFollowView.as_view()),
    path("follow_action", views.FollowActionView.as_view()),
]

3.4 全局路由 & 跨域配置

项目根 urls.py 注册应用:

python

运行

from django.urls import path, include

urlpatterns = [
    path('interaction/', include('interaction.urls')),
]

settings.py 开启跨域(前端小程序联调必备):

python

运行

INSTALLED_APPS = [
    # 其他应用
    'corsheaders',
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',  # 放在最顶部
    # 其他中间件
]

# 本地开发允许所有跨域
CORS_ALLOW_ALL_ORIGINS = True

安装跨域依赖:

bash

运行

pip install django-cors-headers

四、前端实现(UniApp)

4.1 消息中心页面 message_center.vue

核心功能:页面加载请求推荐接口、动态渲染用户列表、头像点击跳转个人主页。

vue

<template>
  <view class="container">
    <!-- 顶部功能入口 -->
    <view class="top-box">
      <view class="item" @click="toLike">
        <image src="/static/like.png" class="icon"></image>
        <text class="txt">赞和收藏</text>
      </view>
      <view class="item" @click="toFollow">
        <image src="/static/follow.png" class="icon"></image>
        <text class="txt">新增关注</text>
      </view>
      <view class="item" @click="toComment">
        <image src="/static/comment.png" class="icon"></image>
        <text class="txt">评论</text>
      </view>
    </view>

    <!-- 私信模块 -->
    <view class="msg-box">
      <text class="title">私信</text>
      <ChatList 
        :msg-list="msgList"
        :loading="loading"
        :user-id="userId"
      />
    </view>

    <!-- 可能感兴趣的人(动态渲染) -->
    <view class="interest-box" v-if="recommendList.length > 0">
      <text class="title">可能感兴趣的人</text>
      <view class="user-list">
        <view class="user-item" v-for="(user, index) in recommendList" :key="index">
          <!-- 头像点击跳转个人主页 -->
          <image 
            :src="user.avatar || '/static/img/avatar.png'" 
            class="avatar"
            mode="aspectFill"
            @click.stop="goBlogHost(user)"
          ></image>
          <view class="info">
            <text class="name">{{ user.name || '用户昵称' }}</text>
            <text class="desc">{{ user.desc || '暂无介绍' }}</text>
          </view>
          <button class="follow-btn">关注</button>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import global from "@/common/global.js"
import ChatList from "@/component/chat_list.vue"
export default {
  components:{ ChatList },
  data() {
    return {
      msgList: [],
      loading: false,
      userId: "",
      recommendList: [] // 推荐用户列表
    }
  },
  onLoad() {
    this.getMsgData()
    this.getRecommendUser()
  },
  methods: {
    getMsgData() {
      this.userId = String(global.getUserId())
      const baseUrl = "http://127.0.0.1:8000"
      uni.request({
        url: `${baseUrl}/getChatList`,
        method: "POST",
        header: {"content-type": "application/json"},
        data: { user_id: this.userId },
        success: (res) => {
          this.msgList = res.data.data || []
        }
      })
    },

    // 请求推荐用户接口
    getRecommendUser() {
      const currentUid = global.getUserId()
      if (!currentUid) return
      const baseUrl = "http://127.0.0.1:8000"
      uni.request({
        url: `${baseUrl}/interaction/recommend_user`,
        method: "POST",
        header: {"content-type": "application/json"},
        data: { user_id: currentUid },
        success: (res) => {
          if (res.data.code === 200) {
            this.recommendList = res.data.data || []
          }
        }
      })
    },

    // 跳转对方个人主页
    goBlogHost(item) {
      if (!item.user_id) {
        uni.showToast({ title: "用户ID异常", icon: "none" });
        return;
      }
      uni.navigateTo({
        url: `/pages/blog/blog_host?host_id=${item.user_id}`
      });
    },

    toLike(){ uni.navigateTo({url:"/pages/message/like_detail"}) },
    toFollow(){ uni.navigateTo({url:"/pages/message/follow_detail"}) },
    toComment(){ uni.navigateTo({url:"/pages/message/comment_detail"}) }
  }
}
</script>

<style scoped>
.container { padding: 20rpx; background-color: #f7f8fa; min-height: 100vh; }
.top-box { display: flex; justify-content: space-around; background: #fff; border-radius: 20rpx; padding: 30rpx 0; margin-bottom: 20rpx; }
.item { display: flex; flex-direction: column; align-items: center; }
.icon { width: 50rpx; height: 50rpx; margin-bottom: 10rpx; }
.txt { font-size: 26rpx; color: #333; }
.msg-box { background: #fff; border-radius: 20rpx; padding: 30rpx; margin-bottom: 20rpx; }
.msg-box .title { font-size: 32rpx; font-weight: bold; color: #2d7d5a; }
.interest-box { background: #fff; border-radius: 20rpx; padding: 30rpx; margin-bottom: 20rpx; }
.interest-box .title { font-size: 32rpx; font-weight: bold; color: #2d7d5a; margin-bottom: 20rpx; }
.user-item { display: flex; align-items: center; }
.avatar { width: 80rpx; height: 80rpx; border-radius: 50%; margin-right: 20rpx; cursor: pointer; }
.info { flex: 1; }
.name { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 6rpx; }
.desc { font-size: 24rpx; color: #999; }
.follow-btn { width: 120rpx; height: 50rpx; line-height: 50rpx; border-radius: 50rpx; background-color: #2d7d5a; color: #fff; font-size: 24rpx; border: none; }
</style>

4.2 个人主页 blog_host.vue

接收跳转参数,加载用户信息、判断关注状态、实现关注 / 取关切换。

vue

<template>
  <view class="host-page">
    <view class="user-card">
      <image class="avatar" :src="userInfo.avatar || '/static/img/avatar.png'" mode="aspectFill"></image>
      <view class="user-info">
        <text class="username">{{ userInfo.username || "匿名用户" }}</text>
        <text class="desc">浏览指数:{{ userInfo.browse_rate || "0,0,0" }}</text>
      </view>
      <button 
        class="follow-btn" 
        :class="isFollow ? 'followed' : ''"
        @click="toggleFollow"
      >
        {{ isFollow ? "已关注" : "关注" }}
      </button>
    </view>
    <view class="divider"></view>
    <view class="content">
      <text>用户博客内容区域</text>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      hostId: "",
      currentUid: "",
      userInfo: {},
      isFollow: false,
      loading: false
    }
  },
  onLoad(option) {
    this.hostId = option.host_id
    this.currentUid = uni.getStorageSync('user_id')
    this.getUserInfo()
    this.checkFollowStatus()
  },
  methods: {
    // 获取用户信息
    async getUserInfo() {
      const baseUrl = "http://127.0.0.1:8000"
      const res = await uni.request({
        url: `${baseUrl}/interaction/get_user_info`,
        method: "POST",
        header: { "content-type": "application/json" },
        data: { user_id: this.hostId }
      })
      if (res.data.code === 200) {
        this.userInfo = res.data.data
      }
    },
    // 查询关注状态
    async checkFollowStatus() {
      const baseUrl = "http://127.0.0.1:8000"
      const res = await uni.request({
        url: `${baseUrl}/interaction/check_follow`,
        method: "POST",
        header: { "content-type": "application/json" },
        data: { self_id: this.currentUid, target_id: this.hostId }
      })
      if (res.data.code === 200) {
        this.isFollow = res.data.is_follow
      }
    },
    // 切换关注/取关
    async toggleFollow() {
      if (this.loading || this.currentUid == this.hostId) {
        uni.showToast({ title: "不能关注自己", icon: "none" })
        return
      }
      this.loading = true
      const baseUrl = "http://127.0.0.1"
      const action = this.isFollow ? 2 : 1
      const res = await uni.request({
        url: `${baseUrl}/interaction/follow_action`,
        method: "POST",
        header: { "content-type": "application/json" },
        data: { self_id: this.currentUid, target_id: this.hostId, action }
      })
      if (res.data.code === 200) {
        this.isFollow = !this.isFollow
        uni.showToast({ title: this.isFollow ? "关注成功" : "已取消关注", icon: "success" })
      }
      this.loading = false
    }
  }
}
</script>

<style scoped>
page { background-color: #f5f5f5; }
.host-page { padding: 20rpx; }
.user-card { display: flex; align-items: center; background: #fff; padding: 30rpx; border-radius: 20rpx; }
.avatar { width: 120rpx; height: 120rpx; border-radius: 50%; margin-right: 30rpx; }
.user-info { flex: 1; }
.username { font-size: 34rpx; font-weight: bold; color: #333; margin-bottom: 12rpx; }
.desc { font-size: 26rpx; color: #666; }
.follow-btn { width: 140rpx; height: 56rpx; line-height: 56rpx; border-radius: 28rpx; background-color: #2d7d5a; color: #fff; border: none; }
.followed { background-color: #cccc; color: #666; }
.divider { height: 1rpx; background: #eee; margin: 30rpx 0; }
.content { background: #fff; padding: 30rpx; border-radius: 20rpx; font-size: 28rpx; }
</style>

五、开发踩坑 & 问题解决(重点)

坑 1:browse_rate 是字符串,直接排序错乱

问题browse_rate 存储 1000,200,300 这类字符串,数据库 / ORM 直接按字符排序,逻辑错误。 解决:后端手动拆分字符串,截取第一个值转为数字类型再排序,同时兼容旧的单值数据。

坑 2:后端排序临时字段导致接口 500 报错

问题:排序用的 sort_num 临时字段被塞进返回数据,前端解析 + 后端取值双重报错。 解决:拆分「排序临时数据」和「最终返回数据」,临时字段不对外输出。

坑 3:前端 Prop 类型警告(数字 vs 字符串)

问题:本地缓存用户 ID 是数字,子组件要求字符串类型,控制台报类型错误。 解决:使用 String(global.getUserId()) 强制类型转换。

坑 4:接口 URL 地址错误

问题:后端接口迁移到 interaction 应用后,前端地址未同步,请求失败。 解决:统一前端请求路径为 /interaction/xxx

坑 5:重复关注问题

问题:用户可以多次点击关注,产生多条重复数据。 解决:数据表设置联合唯一索引,后端使用 get_or_create 避免重复插入。

六、功能整体效果

  1. 数据动态化:彻底抛弃静态写死用户,全部由接口动态拉取;
  2. 智能推荐:自动排除自己,按浏览指数降序,固定展示 4~5 人;
  3. 交互统一:头像跳转逻辑和项目其他页面保持一致,用户体验统一;
  4. 状态联动:个人主页实时识别关注状态,关注 / 取关双向操作,数据持久化;
  5. 高容错:空数据、参数异常、网络错误均有兜底处理。

七、后续优化方向

  1. 增加多维度排序(粉丝数、获赞数),丰富推荐规则;
  2. 推荐列表增加「新动态」小红点提示;
  3. 增加拉黑、屏蔽推荐用户功能;
  4. 前端增加加载动画,优化弱网体验。

总结

本次基于 browse_rate 实现的用户推荐功能,核心难点在于字符串字段的数字排序前后端交互联调。整体采用「接口分层 + 组件化」开发,代码结构清晰、易维护。整套方案可直接复用在博客、社区、社交类项目的用户推荐、好友推荐场景,有需要的小伙伴可以参考借鉴。

更多推荐