ADO.NET防SQL注入与使用参数增删改查

一、sql注入风险及解决方案

SQL注入是指在事先定义好的SQL语句中注入额外的SQL语句,从此来欺骗数据库服务器的行为。

示例:制作会员登录功能。

登录按钮代码如下:

private void btLogin_Click(object sender, EventArgs e)
{
    //1-定义连接字符串
    string connStr = "server=.;database=DBTEST;uid=sa;pwd=123456";
    //2-定义连接对象,打开连接
    SqlConnection conn = new SqlConnection(connStr);
    conn.Open();
    //3-编写sql语句(此处如果用户名密码同时输入' or '1'='1 则可以造成注入)
    string sql = string.Format("select * from Member where MemberAccount='{0}' and MemberPwd='{1}'"
        ,this.txtAccount.Text,this.txtPwd.Text);
    //4-数据适配器抽取信息
    SqlDataAdapter adp = new SqlDataAdapter(sql, conn);
    DataTable dt = new DataTable();  //数据表格
    adp.Fill(dt);
    conn.Close();
    if (dt.Rows.Count == 0)
        MessageBox.Show("用户名或密码错误!");
    else
        MessageBox.Show("登录成功!");
}

备注:如果在用户名和密码输入框中同时输入' or '1'='1 则可以造成注入,直接登录成功,因为已经改变了原来sql语句的含义,在查询条件中有 '1'='1' 的恒等条件。

针对上述登录功能的问题风险有如下解决方案:

方案一:

对危险字符进行判断,在登录代码之前加入如下代码进行判断。

if (this.txtAccount.Text.IndexOf("'") >= 0 || this.txtPwd.Text.IndexOf("'") >= 0)
{
    MessageBox.Show("非法登录!");
    return;
}

方案二:

优化SQL语句,先根据用户名查询,查询有记录在和密码文本框内容进行比对。

private void btLogin_Click(object sender, EventArgs e)
{
    //1-定义连接字符串
    string connStr = "server=.;database=DBTEST;uid=sa;pwd=123456";
    //2-定义连接对象,打开连接
    SqlConnection conn = new SqlConnection(connStr);
    conn.Open();
    //3-编写sql语句
    string sql = string.Format("select * from Member where MemberAccount='{0}'"
        , this.txtAccount.Text);
    //4-数据适配器抽取信息
    SqlDataAdapter adp = new SqlDataAdapter(sql, conn);
    DataTable dt = new DataTable();  //数据表格
    adp.Fill(dt);
    conn.Close();
    if (dt.Rows.Count == 0)
        MessageBox.Show("用户名错误!");
    else
    {
        if (dt.Rows[0]["MemberPwd"].ToString().Equals(this.txtPwd.Text))
            MessageBox.Show("登录成功!");
        else
            MessageBox.Show("密码错误!");
    }
}

方案三:

使用参数化方式编写sql语句

private void btLogin_Click(object sender, EventArgs e)
{
    //1-定义连接字符串
    //string connStr = "Integrated Security=SSPI;Persist Security Info=False;Initial Catalog=DBTEST;Data Source=.";
    //2-编写连接字符串(sql用户名密码方式连接)
    string connStr = "server=.;database=DBTEST;uid=sa;pwd=123456";
    //2-定义连接对象,打开连接
    SqlConnection conn = new SqlConnection(connStr);
    conn.Open();

    //3-编写sql语句
    string sql = "select * from Member where MemberAccount=@MemberAccount and MemberPwd=@MemberPwd";
    //4-数据适配器抽取信息
    SqlDataAdapter adp = new SqlDataAdapter(sql, conn);
    adp.SelectCommand.Parameters.Add(new SqlParameter("@MemberAccount", this.txtAccount.Text));
    adp.SelectCommand.Parameters.Add(new SqlParameter("@MemberPwd",this.txtPwd.Text));
    DataTable dt = new DataTable();  //数据表格
    adp.Fill(dt);
    conn.Close();
    if (dt.Rows.Count == 0)
        MessageBox.Show("用户名或密码错误!");
    else
        MessageBox.Show("登录成功!");
}

二、参数化方式实现增删改查

此示例在之前项目基础上进行修改,主要将添加数据和修改数据修改成参数化方式。

业务需求:

  • (1)窗体加载的时候显示数据。
  • (2)点击"添加数据"按钮,弹出新窗体,在新窗体中进行数据的添加,添加完成后自动刷新表格数据。
  • (3)鼠标选中一行,右键弹出删除菜单,可以删除数据
  • (4)鼠标选中一行,点击"编辑数据"按钮,弹出新窗体,在新窗体中进行数据修改,修改后自动刷新表格数据。

实现步骤如下:

(1)查询窗体显示数据代码:

//绑定数据的方法
public void BindData()
{
    //1-定义连接字符串
    //string connStr = "Integrated Security=SSPI;Persist Security Info=False;Initial Catalog=DBTEST;Data Source=.";
    //2-编写连接字符串(sql用户名密码方式连接)
    string connStr = "server=.;database=DBTEST;uid=sa;pwd=123456";
    //2-定义连接对象,打开连接
    SqlConnection conn = new SqlConnection(connStr);
    conn.Open();
    //3-编写sql语句
    string sql = "select * from Member";
    //4-数据适配器抽取信息
    SqlDataAdapter adp = new SqlDataAdapter(sql, conn);
    DataTable dt = new DataTable();  //数据表格
    adp.Fill(dt);
    this.dataGridView1.AutoGenerateColumns = false;   //自动列取消
    this.dataGridView1.DataSource = dt;
    conn.Close();
}
private void FrmSelect_Load(object sender, EventArgs e)
{
    BindData();
}

(2)删除菜单代码:

private void 删除ToolStripMenuItem_Click(object sender, EventArgs e)
{
    DialogResult r = MessageBox.Show("您确定要删除吗?", "****系统", MessageBoxButtons.YesNo);
    if (r == System.Windows.Forms.DialogResult.No)
    {
        return;
    }
    int memId = int.Parse(this.dataGridView1.SelectedRows[0].Cells[0].Value.ToString());
    string connStr = "Integrated Security=SSPI;Persist Security Info=False;Initial Catalog=DBTEST;Data Source=.";
    SqlConnection conn = new SqlConnection(connStr);
    conn.Open();
    string sql = "delete from Member where MemberId = " + memId;
    SqlCommand cmd = new SqlCommand(sql, conn);
    int rowCount = cmd.ExecuteNonQuery();
    conn.Close();
    if (rowCount == 1)
        MessageBox.Show("删除成功!");
    else
        MessageBox.Show("删除失败!");
    BindData();
}

(3)会员添加窗体代码:

private void btAdd_Click(object sender, EventArgs e)
{
    //1-编写连接字符串(windows方式连接)
    //string connStr = "Integrated Security=SSPI;Persist Security Info=False;Initial Catalog=DBTEST;Data Source=.";
    //2-编写连接字符串(sql用户名密码方式连接)
    string connStr = "server=.;database=DBTEST;uid=sa;pwd=123456";
    //2-创建连接对象,打开数据库连接
    SqlConnection conn = new SqlConnection(connStr);
    conn.Open();
    //3-编写sql语句
    string sql = string.Format("insert into Member(MemberAccount,MemberPwd,MemberName,MemberPhone)  values(@MemberAccount,@MemberPwd,@MemberName,@MemberPhone)"
            , this.txtAccount.Text, this.txtPwd.Text, this.txtNickName.Text, this.txtPhone.Text);
    //4-定义执行命令的对象执行命令
    SqlCommand cmd = new SqlCommand(sql, conn);
    cmd.Parameters.Add(new SqlParameter("@MemberAccount", this.txtAccount.Text));
    cmd.Parameters.Add(new SqlParameter("@MemberPwd", this.txtPwd.Text));
    cmd.Parameters.Add(new SqlParameter("@MemberName", this.txtNickName.Text));
    cmd.Parameters.Add(new SqlParameter("@MemberPhone", this.txtPhone.Text));
    int rowCount = cmd.ExecuteNonQuery();
    conn.Close();
    if (rowCount == 1)
        MessageBox.Show("添加成功!");
    else
        MessageBox.Show("添加失败!");
    //刷新查询窗体数据并关闭当前窗体
    ((FrmSelect)this.Owner).BindData();
    this.Close();
}

(4)会员编辑窗体代码:

public int MemId { get; set; }  //接受外部传递过来的会员编号
//绑定会员详情到文本框
private void BindDetail()
{
    //1-定义连接字符串
    string connStr = "Integrated Security=SSPI;Persist Security Info=False;Initial Catalog=DBTEST;Data Source=.";
    //2-定义连接对象,打开连接
    SqlConnection conn = new SqlConnection(connStr);
    conn.Open();
    //3-编写sql语句
    string sql = "select * from Member where MemberId = " + this.MemId;
    //-抽取数据
    SqlDataAdapter adp = new SqlDataAdapter(sql, conn);
    DataTable dt = new DataTable();
    adp.Fill(dt);
    conn.Close();
    this.txtAccount.Text = dt.Rows[0]["MemberAccount"].ToString();
    this.txtPwd.Text = dt.Rows[0]["MemberPwd"].ToString();
    this.txtNickName.Text = dt.Rows[0]["MemberName"].ToString();
    this.txtPhone.Text = dt.Rows[0]["MemberPhone"].ToString();
}
private void FrmEdit_Load(object sender, EventArgs e)
{
    BindDetail();
}
private void btUpdate_Click(object sender, EventArgs e)
{
    string connStr = "Integrated Security=SSPI;Persist Security Info=False;Initial Catalog=DBTEST;Data Source=.";
    SqlConnection conn = new SqlConnection(connStr);
    conn.Open();
    string sql = "update Member set MemberAccount=@MemberAccount,MemberPwd=@MemberPwd,MemberName=@MemberName,MemberPhone=@MemberPhone where MemberId=@MemberId";
    SqlCommand cmd = new SqlCommand(sql, conn);
    cmd.Parameters.Add(new SqlParameter("@MemberAccount", this.txtAccount.Text));
    cmd.Parameters.Add(new SqlParameter("@MemberPwd", this.txtPwd.Text));
    cmd.Parameters.Add(new SqlParameter("@MemberName", this.txtNickName.Text));
    cmd.Parameters.Add(new SqlParameter("@MemberPhone", this.txtPhone.Text));
    cmd.Parameters.Add(new SqlParameter("@MemberId", this.MemId));
    int rowCount = cmd.ExecuteNonQuery();
    conn.Close();
    if (rowCount == 1)
        MessageBox.Show("修改成功!");
    else
        MessageBox.Show("修改失败!");
    //刷新查询窗体数据并关闭当前窗体
    ((FrmSelect)this.Owner).BindData();
    this.Close();
}

(5)查询窗体"添加数据"和"编辑数据"按钮的代码:

private void btAdd_Click(object sender, EventArgs e)
{
    FrmAdd frm = new FrmAdd();
    frm.Owner = this;
    frm.Show();
}
private void btEdit_Click(object sender, EventArgs e)
{
    if (this.dataGridView1.SelectedRows[0].Cells[0].Value.ToString().Equals(""))
    {
        MessageBox.Show("请正确选择!");
        return;
    }
    int memId = int.Parse(this.dataGridView1.SelectedRows[0].Cells[0].Value.ToString());
    FrmEdit frm = new FrmEdit();
    frm.MemId = memId;
    frm.Owner = this;
    frm.Show();
}

三、封装DBHelper类

class DBHelper
{
    //SQL连接字符串-SQL身份认证方式登录
    public static string connStr = "server=.;database=DBTEST;uid=sa;pwd=123456;";

    //SQL连接字符串-Windows身份认证方式登录
    //public static string connStr = "Integrated Security=SSPI;Persist Security Info=False;Initial Catalog=DBTEST;Data Source=.";

    //读取配置文件appSettings节点读取字符串(需要添加引用System.Configuration)
    //public static string connStr = ConfigurationManager.AppSettings["DefaultConn"].ToString();
    //对应的配置文件如下:
    //<appSettings>
    //  <add key="DefaultConn" value="Integrated Security=SSPI;Persist Security Info=False;Initial Catalog=DBTEST;Data Source=."/>
    //</appSettings>

    //读取配置文件ConnectionStrings节点读取字符串(需要添加引用System.Configuration)
    //public static string connStr = ConfigurationManager.ConnectionStrings["DefaultConn"].ConnectionString;
    //对应配置文件如下:
    //<connectionStrings>
    //    <add name="DefaultConn" connectionString="Integrated Security=SSPI;Persist Security Info=False;Initial Catalog=DBTEST;Data Source=."/>
    //</connectionStrings>

    public static SqlConnection conn = null;
    public static SqlDataAdapter adp = null;

    #region 连接数据库
    /// <summary>
    /// 连接数据库
    /// </summary>
    public static void OpenConn()
    {
        if (conn == null)
        {
            conn = new SqlConnection(connStr);
            conn.Open();
        }
        if (conn.State == System.Data.ConnectionState.Closed)
        {
            conn.Open();
        }
        if (conn.State == System.Data.ConnectionState.Broken)
        {
            conn.Close();
            conn.Open();
        }
    }
    #endregion

    #region 执行SQL语句前准备
    /// <summary>
    /// 准备执行一个SQL语句
    /// </summary>
    /// <param name="sql">需要执行的SQL语句</param>
    public static void PrepareSql(string sql)
    {
        OpenConn(); //打开数据库连接
        adp = new SqlDataAdapter(sql, conn);
    }
    #endregion

    #region 设置和获取sql语句的参数
    /// <summary>
    /// 设置传入参数
    /// </summary>
    /// <param name="parameterName">参数名称</param>
    /// <param name="parameterValue">参数值</param>
    public static void SetParameter(string parameterName, object parameterValue)
    {
        parameterName = "@" + parameterName.Trim();
        if (parameterValue == null)
            parameterValue = DBNull.Value;
        adp.SelectCommand.Parameters.Add(new SqlParameter(parameterName, parameterValue));
    }
    #endregion

    #region 执行SQL语句
    /// <summary>
    /// 执行非查询SQL语句
    /// </summary>
    /// <returns>受影响行数</returns>
    public static int ExecNonQuery()
    {
        int result = adp.SelectCommand.ExecuteNonQuery();
        conn.Close();
        return result;
    }
    /// <summary>
    /// 执行查询SQL语句
    /// </summary>
    /// <returns>DataTable类型查询结果</returns>
    public static DataTable ExecQuery()
    {
        DataTable dt = new DataTable();
        adp.Fill(dt);
        conn.Close();
        return dt;
    }
    /// <summary>
    /// 执行查询SQL语句
    /// </summary>
    /// <returns>SqlDataReader类型查询结果,SqlDataReader需要手动关闭</returns>
    public static SqlDataReader ExecDataReader()
    {
        return adp.SelectCommand.ExecuteReader(CommandBehavior.CloseConnection);
    }
    /// <summary>
    /// 执行查询SQL语句
    /// </summary>
    /// <returns>查询结果第一行第一列</returns>
    public static object ExecScalar()
    {
        object obj = adp.SelectCommand.ExecuteScalar();
        conn.Close();
        return obj;
    }
    #endregion
}

到此这篇关于ADO.NET防SQL注入与使用参数增删改查的文章就介绍到这了。希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • ADO.NET通用数据库访问类

    本文实例为大家分享了ADO.NET通用数据库访问类,供大家参考学习,具体内容如下 using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Data; using System.Data.SqlClient; namespace Test { public class DBHelper { publ

  • ADO.NET数据库访问技术

    一. ADO.NET的定义 ADO.NET来源于COM组件库ADO(即ActiveX Data Objects),是微软公司新一代.NET数据库的访问模型,是目前数据库程序设计人员用来开发基于.NET的数据库应用程序的主要接口.它利用.NET Data Provider(数据提供程序)进行数据库的连接和访问,通过ADO.NET数据库程序能够使用各种对象来访问符合条件的数据库内容,让提供数据库管理系统的各个厂商可以根据此标准开放对应的.NET Data Provider,这样设计数据库应用程序人员

  • ADO.NET基础知识汇总

    虽然我们都知道ADO.NET是对数据库的操作,但是要真的说出ADO.NET的具体含义还不是很容易. ADO.NET是ActiveX Data Objects的缩写,它是一个COM组件库,用于在microsoft技术中访问数据.之所以叫ADO.NET,应该是微软自己打的广告,希望在NET编程环境中优先使用这种数据访问接口.上面这段话基本来自百度百科.简单来说,ADO.NET就是一种数据访问接口,可以让我们在程序中调用相应的类库对数据库(通常为SQL Server,也可以是access 等其他数据库

  • ADO.NET编程之基础知识

    一.ADO.NET基础 程序和数据库交互,要通过ADO.NET进行:通过ADO.NET就能在数据库中执行SQL了.ADO.NET中提供了对不同数据库的统一操作接口(ODBC).另外还有一种操作数据库的接口是JDBC. ADO.NET中通过SqlConnection类创建到SQL Server的链接,Sqlconnection代表一个数据库的链接,ADO.NET中的链接等资源都实现了IDisposable接口. 实现了IDisposable接口的对象,在使用完了,要进行资源的释放.调用Dispos

  • 浅谈ADO.NET数据库脚本

    这次我使用ADO.NET来插入一条数据,到数据库中.主用到存储过程.我不想每次都是用SQL文本的形式了,那样始终没有进步--- 下面首先,我把我这次练习要用到的数据库脚本,贴出来: USE master --使用系统数据库 GO IF EXISTS(SELECT * FROM sysdatabases WHERE name=N'DB_MyStudentLife') DROP DATABASE [DB_MyStudentLife]; --如果要创建的数据库存在的话,就删除 GO CREATE DA

  • C#  ADO.NET 离线查询的实现示例

    0. 前言 在上一篇中,我故意留下了查询的示范没讲.虽然说可以通过以下代码获取一个DataReader: IDataReader reader = command.ExecuteReader(); 然后通过reader一行一行的读取数据,但是我并不推荐这样使用. 在查询这一高频需求上,C#为之做了很多工作,提供了更多的选择.这里介绍一个查询的另一套写法. 1. 离线查询 C#在查询上提供了另一种机制,可以一次性从数据库把结果读取到网络缓存区中,直到使用的时候才加载到程序中. 在离线查询里最关键的

  • ADO.NET基础知识详解

    ADO.NET是微软提供的一种数据库访问技术. ADO.NET为不同类型的数据源提供了不同的数据提供程序对象: 数据提供程序 说明 SQL Server 数据提供程序 提供对Microsoft SQL Server中数据的访问,使用System.Data.SqlClient命名空间. OLE 数据提供程序 提供对使用OLE DB公开的数据源(如Access.Excel等)中数据的访问,使用System.Data.oleDb命名空间. ODBC 数据提供程序 提供对使用ODBC公开的数据源中数据的

  • ADO.NET制做一个登录案例

    总体思路.根据用户输入的用户名和密码,来判断,和数据库里面存的是不是一样,如果一样就表明登录成功,否则就登录失败. 方案一: 1.select* from 表名 where username="用户输入的用户名"  2.如果存在 reader.Read(),即用户名存在,接着就判断用户输入的密码,和取到的密码(reader.GetString(reader.GetOridinal("密码字段")))是不是一样,如果一样就登录成功,否则就登录失败. 方案二:  sel

  • ADO.NET实用经验汇总

    DataSet与DataReader 当设计应用程序时,要考虑应用程序所需功能的等级,以确定使用DataSet或者是DataReader. 要通过应用程序执行以下操作,就要使用DataSet: 1) 在结果的多个离散表之间进行导航. 2) 操作来自多个数据源(例如,来自多个数据库.一个XML文件和一个电子表格的混合数据)的数据. 3) 在各层之间交换数据或使用XML Web服务.与DataReader不同的是,DataSet能传递给远程客户端. 4) 重用同样的记录集合,以便通过缓存获得性能改善

  • ADO.NET防SQL注入与使用参数增删改查

    一.sql注入风险及解决方案 SQL注入是指在事先定义好的SQL语句中注入额外的SQL语句,从此来欺骗数据库服务器的行为. 示例:制作会员登录功能. 登录按钮代码如下: private void btLogin_Click(object sender, EventArgs e) { //1-定义连接字符串 string connStr = "server=.;database=DBTEST;uid=sa;pwd=123456"; //2-定义连接对象,打开连接 SqlConnectio

  • Sql Server 视图数据的增删改查教程

    一.视图的基本概念 视图是用于查询的另外一种方式. 与实际的表不同,它是一个虚表:因此数据库中只存在视图的定义,而不存在视图中相对应的数据,数据仍然存放在原来的基本表中.视图是一种逻辑对象. 二. 视图能做什么 导出数据: 在导出数据时,常常需要多个表联合查询,这个时候就可以使用视图:转换字段,如把status(0/1 Int类型)字段转换成启用/停用(文字) ,然后用视图保存定义. 数据安全,可以给视图加密. 三.视图操作 3.1 查询数据 (一)准备素材: 使用两张表,其中一张是家电表,另外

  • php mysql_real_escape_string addslashes及mysql绑定参数防SQL注入攻击

    php mysql_real_escape_string addslashes及mysql绑定参数防SQL注入攻击 php防止SQL注入攻击一般有三种方法: 使用mysql_real_escape_string函数 使用addslashes函数 使用mysql bind_param() 本文章向大家详细介绍这三个方法在防止SQL注入攻击中的效果及区别. mysql_real_escape_string防sql注入攻击 mysql_real_escape_string() 函数转义 SQL 语句中

  • C#防SQL注入代码的三种方法

    对于网站的安全性,是每个网站开发者和运营者最关心的问题.网站一旦出现漏洞,那势必将造成很大的损失.为了提高网站的安全性,首先网站要防注入,最重要的是服务器的安全设施要做到位. 下面说下网站防注入的几点要素. 一:丢弃SQL语句直接拼接,虽然这个写起来很快很方便. 二:如果用SQL语句,那就使用参数化,添加Param 三:尽可能的使用存储过程,安全性能高而且处理速度也快 四:屏蔽SQL,javascript等注入(很是主要的),对于每个文件写是不太可能的.所以要找到对所有文件起作用的办法.我在网上

  • 防SQL注入 生成参数化的通用分页查询语句

    使用这种通用的存储过程进行分页查询,想要防SQL注入,只能对输入的参数进行过滤,例如将一个单引号"'"转换成两个单引号"''",但这种做法是不安全的,厉害的黑客可以通过编码的方式绕过单引号的过滤,要想有效防SQL注入,只有参数化查询才是最终的解决方案.但问题就出在这种通用分页存储过程是在存储过程内部进行SQL语句拼接,根本无法修改为参数化的查询语句,因此这种通用分页存储过程是不可取的.但是如果不用通用的分页存储过程,则意味着必须为每个具体的分页查询写一个分页存储过程

  • PHP实现表单提交数据的验证处理功能【防SQL注入和XSS攻击等】

    本文实例讲述了PHP实现表单提交数据的验证处理功能.分享给大家供大家参考,具体如下: 防XSS攻击代码: /** * 安全过滤函数 * * @param $string * @return string */ function safe_replace($string) { $string = str_replace('%20','',$string); $string = str_replace('%27','',$string); $string = str_replace('%2527',

  • .Net防sql注入的几种方法

    防sql注入的常用方法: 1.服务端对前端传过来的参数值进行类型验证: 2.服务端执行sql,使用参数化传值,而不要使用sql字符串拼接: 3.服务端对前端传过来的数据进行sql关键词过来与检测: 着重记录下服务端进行sql关键词检测: 1.sql关键词检测类: public class SqlInjectHelper:System.Web.UI.Page { private static string StrKeyWord = "select|insert|delete|from|count(

  • java 过滤器filter防sql注入的实现代码

    实例如下: XSSFilter.java public void doFilter(ServletRequest servletrequest, ServletResponse servletresponse, FilterChain filterchain) throws IOException, ServletException { //flag = true 只做URL验证; flag = false 做所有字段的验证; boolean flag = true; if(flag){ //只

  • 在Global.asax文件里实现通用防SQL注入漏洞程序(适应于post/get请求)

    首先,创建一个SQLInjectionHelper类完成恶意代码的检查 代码如下: 复制代码 代码如下: using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Text.RegularExpressions; /// <summary> ///SQLInjectionHelper 的摘要说明 /// </summary> public cla

  • jquery过滤特殊字符',防sql注入的实现方法

    今天写的代码给项目经理看了下,因为之前没有考虑sql注入的问题,然后在他测试我的code的时候,打了一个"'",然后我的程序就挂了! 于是乎,我在网上找到了一个验证并过滤文本框的jquery! 先上图: PS:这里用@#测试,因为'太小了,都看不清楚了! 具体的jquery代码: <script type="text/javascript" language="javascript"> $(document).ready(functi

随机推荐