[Go]GopherAI项目学习记录:用户模块

一个网页AI聊天,图像识别项目,使用到的技术栈有,Gin框架,GORM,rabbitmq,redis,eino框架,Vue框架等

项目地址:https://github.com/youngyangyang04/GopherAI


本项目能大体分成三个模块:

  • 用户模块,负责用户的注册,登录等
  • AI聊天模块,创建和管理AI会话,调用大模型API或本地模型
  • 图像识别模块,使用本地模型推理图像识别

现在从各个架构层次,自顶向下地介绍用户模块的实现

路由层

首先在router/touter.go中,在创建了gin.Engine之后创建了/user路由组

// 初始化URL路由规则
func InitRouter() *gin.Engine {

    r := gin.Default()
    enterRouter := r.Group("/api/v1")   // 创建路由组
    {
        RegisterUserRouter(enterRouter.Group("/user"))
    }
    ...
}

一共有三个API,分别是登录,注册和验证码

// 为接口路径设置handler函数
func RegisterUserRouter(r *gin.RouterGroup) {
    {
        r.POST("/register", user.Register)          // 注册
        r.POST("/login", user.Login)                // 登录
        r.POST("/captcha", user.HandleCaptcha)      // 验证码
    }
}

控制器层

下面来看控制器层,在用户模块中控制器层主要包含将三个API接口绑定的三个handler函数,用于获取参数并调用服务层方法

同时在controller/common.go中,还定义了统一的响应结构体(状态码+状态信息),状态码定义于common/code.go

package code

// 常量Code定义状态码(int64)
type Code int64

const (
    CodeSuccess          Code = 1000

    CodeInvalidParams    Code = 2001
    CodeUserExist        Code = 2002
    CodeUserNotExist     Code = 2003
    CodeInvalidPassword  Code = 2004
    CodeNotMatchPassword Code = 2005
    CodeInvalidToken     Code = 2006
    CodeNotLogin         Code = 2007
    CodeInvalidCaptcha   Code = 2008
    CodeRecordNotFound   Code = 2009
    CodeIllegalPassword  Code = 2010

    CodeForbidden        Code = 3001

    CodeServerBusy       Code = 4001

    AIModelNotFind       Code = 5001
    AIModelCannotOpen    Code = 5002
    AIModelFail          Code = 5003
)

// Code和message的映射
var msg = map[Code]string{
    CodeSuccess:          "success",

    CodeInvalidParams:    "请求参数错误",
    CodeUserExist:        "用户名已存在",
    CodeUserNotExist:     "用户不存在",
    CodeInvalidPassword:  "用户名或密码错误",
    CodeNotMatchPassword: "两次密码不一致",
    CodeInvalidToken:     "无效的Token",
    CodeNotLogin:         "用户未登录",
    CodeInvalidCaptcha:   "验证码错误",
    CodeRecordNotFound:   "记录不存在",
    CodeIllegalPassword:  "密码不合法",

    CodeForbidden:        "权限不足",

    CodeServerBusy:       "服务繁忙",

    AIModelNotFind:       "模型不存在",
    AIModelCannotOpen:    "无法打开模型",
    AIModelFail:          "模型运行失败",
}

// 将Code转为int64
func (code Code) Code() int64 {
    return int64(code)
}

// Msg 获取响应消息
func (code Code) Msg() string {
    if m, ok := msg[code]; ok {
        return m
    }
    //  获取映射失败,返回服务器繁忙
    return msg[CodeServerBusy]
}
package controller

import "GopherAI/common/code"

// 统一的响应结构
type Response struct {
    StatusCode code.Code `json:"status_code"`
    StatusMsg  string    `json:"status_msg,omitempty"`
}

// 设置状态码和消息
func (r *Response) CodeOf(code code.Code) Response {
    if nil == r {
        r = new(Response)
    }
    r.StatusCode = code
    r.StatusMsg = code.Msg()
    return *r
}

// 设置为成功状态
func (r *Response) Success() {
    r.CodeOf(code.CodeSuccess)
}

定义了统一的响应格式之后,在controller/user/user.go中实现了三个handler函数

在这之前,当然还是先定义了接口的请求/响应JSON结构体

type (
    // 登录请求结构体封装
    // 这里的Username只能是账号登录,和我做的另一个项目有区别(邮箱账号均可)
    LoginRequest struct {
        Username string `json:"username"`
        Password string `json:"password"`
    }

    // 登录响应结构体封装
    // omitempty当字段为空的时候,不返回这个东西
    LoginResponse struct {
        controller.Response
        Token string `json:"token,omitempty"`   // omitempty表示如果没有这项就直接不解析出来
    }

    // 注册请求结构体封装
    // 验证码由后端生成,存放到redis中,固然需要先发送一次请求CaptchaRequest,然后用返回的验证码
    // 邮箱以及密码进行注册,后续再将账号进行返回
    RegisterRequest struct {
        Email    string `json:"email" binding:"required"`   // required表示让Gin自动校验不能为空
        Captcha  string `json:"captcha"`
        Password string `json:"password"`
    }

    // 注册响应结构体封装
    // 注册成功之后,直接让其进行登录状态
    RegisterResponse struct {
        controller.Response
        Token string `json:"token,omitempty"`
    }

    // 验证码请求结构体封装
    CaptchaRequest struct {
        Email string `json:"email" binding:"required"`
    }

    // 验证码响应结构体封装
    CaptchaResponse struct {
        controller.Response
    }
)

先来看/login接口的handler函数,流程是先从响应中获取参数,随后调用service层的Login函数,执行真正的登录逻辑,完成后构造响应并返回

// /login接口的handler函数
func Login(c *gin.Context) {
    // 定义请求与响应结构体
    req := new(LoginRequest)
    res := new(LoginResponse)
    // 绑定和验证参数
    if err := c.ShouldBindJSON(req); err != nil {
        c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidParams))
        return
    }
    // 调用service层函数Login
    token, code_ := user.Login(req.Username, req.Password)
    // 构造响应
    if code_ != code.CodeSuccess {
        c.JSON(http.StatusOK, res.CodeOf(code_))
        return
    }
    res.Success()
    res.Token = token
    // 返回响应
    c.JSON(http.StatusOK, res)
}

Register和Captcha函数也是类似的流程

// /register接口的handler函数
func Register(c *gin.Context) {
    // 定义请求与响应结构体
    req := new(RegisterRequest)
    res := new(RegisterResponse)
    // 绑定和验证参数
    if err := c.ShouldBindJSON(req); err != nil {
        c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidParams))
        return
    }
    // 调用service层函数Register
    token, code_ := user.Register(req.Email, req.Password, req.Captcha)
    // 构造响应
    if code_ != code.CodeSuccess {
        c.JSON(http.StatusOK, res.CodeOf(code_))
        return
    }
    res.Success()
    res.Token = token
    // 返回响应
    c.JSON(http.StatusOK, res)
}
// /captcha接口的handler函数
func HandleCaptcha(c *gin.Context) {
    // 构造请求和响应结构体
    req := new(CaptchaRequest)
    res := new(CaptchaResponse)
    // 解析参数
    if err := c.ShouldBindJSON(req); err != nil {
        c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidParams))
        return
    }

    // 给service层进行处理,给邮箱发送验证码
    code_ := user.SendCaptcha(req.Email)
    if code_ != code.CodeSuccess {
        c.JSON(http.StatusOK, res.CodeOf(code_))
        return
    }

    // 返回响应
    // 匿名字段,其实本身res.Success()调用就是res.Response.Success()
    // 即res.Response.Success()
    res.Success()
    c.JSON(http.StatusOK, res)
}

服务层

控制器层之后是服务层,执行真正的业务逻辑,用户模块的service层主要包含三个函数,也就是登录,注册和发送验证码

用户模块还依赖了MySQL,Redis,JWT,SMTP,MD5加密等服务或功能

先来看登录是如何实现的,首先会调用数据库查询功能,查询用户名是否存在,之后再对比MD5加密后的密码是否和数据库中的一致,然后便可认证用户,生成并返回一个token(该简易登录系统采用了无状态的JWT,token本身包含用户信息,服务端不储存token)

// 尝试登录用户,返回token和状态码
func Login(username, password string) (string, code.Code) {
    var userInformation *model.User
    var ok bool
    // 1:判断用户是否存在
    if ok, userInformation = user.IsExistUser(username); !ok {

        return "", code.CodeUserNotExist
    }
    // 2:判断用户是否密码账号正确
    if userInformation.Password != utils.MD5(password) {
        return "", code.CodeInvalidPassword
    }
    // 3:返回一个Token
    token, err := myjwt.GenerateToken(userInformation.ID, userInformation.Username)

    if err != nil {
        return "", code.CodeServerBusy
    }
    return token, code.CodeSuccess
}

随后再来看注册功能的实现,和登录功能一样,会先判断用户是否存在(这里有bug,判断的时候传入的是邮箱,但是数据库存的是11位随机用户名),然后调用redis检查前端发来的验证码是否正确,一切检查通过后再生成11位随机数字用户名,插入数据库,发送邮件告诉用户,最后再返回token

// 尝试注册用户,返回token和状态码
func Register(email, password, captcha string) (string, code.Code) {

    var ok bool
    var userInformation *model.User

    // 1:先判断用户是否已经存在了
    if ok, _ := user.IsExistUser(email); ok {
        return "", code.CodeUserExist
    }

    // 2:从redis中验证验证码是否有效
    if ok, _ := myredis.CheckCaptchaForEmail(email, captcha); !ok {
        return "", code.CodeInvalidCaptcha
    }

    // 3:生成11位的账号
    username := utils.GetRandomNumbers(11)

    // 4:注册到数据库中
    if userInformation, ok = user.Register(username, email, password); !ok {
        return "", code.CodeServerBusy
    }

    // 5:将账号一并发送到对应邮箱上去,后续需要账号登录
    if err := myemail.SendCaptcha(email, username, user.UserNameMsg); err != nil {
        return "", code.CodeServerBusy
    }

    // 6:生成Token
    token, err := myjwt.GenerateToken(userInformation.ID, userInformation.Username)

    if err != nil {
        return "", code.CodeServerBusy
    }

    return token, code.CodeSuccess
}

最后再来看发送验证码功能的实现,验证码是随机的6位数字,会先存放到redis中,再通过给邮箱发给用户

// 往指定邮箱发送验证码
// 分为以下任务:
// 1:先存放redis
// 2:再进行远程发送
func SendCaptcha(email_ string) code.Code {
    send_code := utils.GetRandomNumbers(6)
    // 1:先存放到redis
    if err := myredis.SetCaptchaForEmail(email_, send_code); err != nil {
        return code.CodeServerBusy
    }

    // 2:再进行远程发送
    if err := myemail.SendCaptcha(email_, send_code, myemail.CodeMsg); err != nil {
        return code.CodeServerBusy
    }

    return code.CodeSuccess
}

数据访问层

DAO层里实现了两个方法:判断用户是否存在和将用户插入数据库,使用gorm实现

判断用户是否存在功能会在数据库中查找username为传入字符串的数据项

// 返回用户名是否存在
// 这边只能通过账号进行登录
func IsExistUser(username string) (bool, *model.User) {

    user, err := mysql.GetUserByUsername(username)

    if err == gorm.ErrRecordNotFound || user == nil {
        return false, nil
    }

    return true, user
}

注册用户到数据库则会将用户信息封装为User数据模型,插入数据库中

// 注册用户到数据库
func Register(username, email, password string) (*model.User, bool) {
    // 将用户插入数据库
    if user, err := mysql.InsertUser(&model.User{
        Email:    email,
        Name:     username,
        Username: username,
        Password: utils.MD5(password),  // 密码进行MD5加密
    }); err != nil {
        return nil, false
    } else {
        return user, true
    }
}

数据库操作

在model/user.go中定义了用户的数据模型,对应MySQL中的User表,使用ID作为主键,用于唯一标识用户

// 用户结构体(数据模型)
type User struct {
    ID        int64          `gorm:"primaryKey" json:"id"`                          // 用户ID
    Name      string         `gorm:"type:varchar(50)" json:"name"`                  // 用户Name
    Email     string         `gorm:"type:varchar(100);index" json:"email"`          // 用户邮箱
    Username  string         `gorm:"type:varchar(50);uniqueIndex" json:"username"`  // 用户名(唯一索引)
    Password  string         `gorm:"type:varchar(255)" json:"-"`                    // 密码(不返回给前端)
    CreatedAt time.Time      `json:"created_at"`                                    // 创建时间按(自动时间戳)
    UpdatedAt time.Time      `json:"updated_at"`                                    // 更新时间
    DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`                                // 支持软删除
}

在common/mysql/mysql.go中,写了数据库的初始化以及用户相关的数据库操作函数

先来看数据库初始化函数,使用了一个全局的数据库实例*grom.DB,函数回先从配置文件中获取配置项,包含MySQL的主机,端口,数据库名称,MySQL用户名,密码以及使用的字符编码

随后设置好gorm的日志器,连接数据库

配置连接池,最后使用gorm的自动迁移功能,创建user,session,message的表

// 全局数据库实例
var DB *gorm.DB

// 初始化数据库
func InitMysql() error {
    // 获取配置项
    host := config.GetConfig().MysqlHost
    port := config.GetConfig().MysqlPort
    dbname := config.GetConfig().MysqlDatabaseName
    username := config.GetConfig().MysqlUser
    password := config.GetConfig().MysqlPassword
    charset := config.GetConfig().MysqlCharset

    // 构建连接数据库字符串
    dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=Local", username, password, host, port, dbname, charset)

    // 设置日志
    var log logger.Interface
    if gin.Mode() == "debug" {
        log = logger.Default.LogMode(logger.Info)
    } else {
        log = logger.Default
    }

    // 连接数据库
    db, err := gorm.Open(mysql.New(mysql.Config{
        DSN:                       dsn,
        DefaultStringSize:         256,
        DisableDatetimePrecision:  true,
        DontSupportRenameIndex:    true,
        DontSupportRenameColumn:   true,
        SkipInitializeWithVersion: false,
    }), &gorm.Config{
        Logger: log,
    })
    if err != nil {
        return err
    }

    // 获取底层DB实例
    sqlDB, err := db.DB()
    if err != nil {
        return err
    }
    sqlDB.SetMaxIdleConns(10)               // 最大空闲连接数10
    sqlDB.SetMaxOpenConns(100)              // 最大打开连接数100
    sqlDB.SetConnMaxLifetime(time.Hour)     // 连接存活最长时间

    DB = db

    // 迁移,创建表
    return migration()
}

// 迁移
func migration() error {
    // 自动根据传入的数据模型创建或更新表
    return DB.AutoMigrate(
        new(model.User),
        new(model.Session),
        new(model.Message),
    )
}

插入用户函数

// 插入用户
func InsertUser(user *model.User) (*model.User, error) {
    err := DB.Create(&user).Error
    return user, err
}

获取用户函数

// 使用用户名获取用户
func GetUserByUsername(username string) (*model.User, error) {
    user := new(model.User)
    err := DB.Where("username = ?", username).First(user).Error
    return user, err
}

JWT中间件

在utils/myjwt/jwt.go中实现了生成和解析用户token的功能

首先是自定义的Claims结构体,包含了jwt的标准字段

// 包含了jwt标准字段(jwt.RegisteredClaims)的自定义claims
type Claims struct {
    ID       int64  `json:"id"`             // 用户id
    Username string `json:"username"`       // 用户名
    jwt.RegisteredClaims
}

然后来看为用户生成token的函数,构建Claims结构体,使用HS256算法签名,最后返回最终jwt字符串

// 为用户生成token
func GenerateToken(id int64, username string) (string, error) {
    claims := Claims{
        ID:       id,           // 用户ID
        Username: username,     // 用户名
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(config.GetConfig().ExpireDuration) * time.Hour)),    // 过期时间(从配置中加载)
            Issuer:    config.GetConfig().Issuer,           // 签发者(从配置中加载)
            Subject:   config.GetConfig().Subject,          // 主题(从配置中加载)
            IssuedAt:  jwt.NewNumericDate(time.Now()),      // 签发时间(从配置中加载)
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)  // 生成token
    return token.SignedString([]byte(config.GetConfig().Key))   // 返回用密钥签名产生的最终字符串
}

最后来看解析token的函数,逆向解析出token字符串包含的Claims结构体,并校验合法性,最后返回username

// 解析Token
func ParseToken(token string) (string, bool) {
    claims := new(Claims)
    // 解析
    t, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
        return []byte(config.GetConfig().Key), nil
    })
    // 校验合法性
    if !t.Valid || err != nil || claims == nil {
        return "", false
    }
    return claims.Username, true    // 解析成功,返回用户名
}

以上是底层的jwt操作,现在来看中间件的逻辑,在middleware/jwt/jwt.go中实现了Auth()函数,会返回一个函数签名为gin.HandlerFunc的函数,用于jwt鉴权

before逻辑是从请求头或者URL中拿到token并解析,获取其中包含的用户名信息,将用户信息写入gin.Context中,随后执行c.Next()

after逻辑这里没写

func Auth() gin.HandlerFunc {
    // 返回一个gin.HandleerFunc(中间件函数)
    return func(c *gin.Context) {
        // 中间件before逻辑
        res := new(controller.Response)

        // 从请求头Authorization里获取token(前缀为Bearer)
        var token string
        authHeader := c.GetHeader("Authorization")
        if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
            token = strings.TrimPrefix(authHeader, "Bearer ")
        } else {
            // 兼容 URL 参数传 token(?token=xxx)
            token = c.Query("token")
        }

        // 如果token为空直接返回错误code并终止请求,handler不会被执行
        if token == "" {
            c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidToken))
            c.Abort()
            return
        }

        // 解析token,返回用户名
        userName, ok := myjwt.ParseToken(token)
        if !ok {
            c.JSON(http.StatusOK, res.CodeOf(code.CodeInvalidToken))
            c.Abort()
            return
        }

        c.Set("userName", userName)     // 把用户信息写入上下文
        c.Next()                        // 中间件调用c.Next(),允许请求执行handler
        // 中间件after逻辑(这里没有)
    }
}

redis的使用

使用redis来存储邮箱验证码,使用”captcha:email”为key,验证码为value的格式存储

在commom/redis/key.go中实现了key的格式

// 获取邮箱key(由于redis是全局KV,故key需要前缀区分服务)
// key:特定邮箱-> 验证码
func GenerateCaptcha(email string) string {
    return fmt.Sprintf(config.DefaultRedisKeyConfig.CaptchaPrefix, email)
}

在commom/redis/redis.go中实现了初始化redis,存验证码,校验验证码的功能

先来看redis的初始化函数,先从配置文件中加载配置项的值,然后调用redis.NewClient连接redis

var Rdb *redis.Client               // 全局redis客户端

var ctx = context.Background()      // 上下文

// 初始化redis连接
func Init() {
    conf := config.GetConfig()
    host := conf.RedisConfig.RedisHost
    port := conf.RedisConfig.RedisPort
    password := conf.RedisConfig.RedisPassword
    db := conf.RedisDb
    addr := host + ":" + strconv.Itoa(port)

    Rdb = redis.NewClient(&redis.Options{
        Addr:     addr,
        Password: password,
        DB:       db,
    })

}

再来看存邮箱验证码的函数(两分钟后超时删除)

// 存验证码(key:email val:captcha TTL:2min)
func SetCaptchaForEmail(email, captcha string) error {
    key := GenerateCaptcha(email)
    expire := 2 * time.Minute
    return Rdb.Set(ctx, key, captcha, expire).Err()
}

最后来看校验验证码的函数

// 校验验证码
func CheckCaptchaForEmail(email, userInput string) (bool, error) {
    // 获取邮箱对应的key
    key := GenerateCaptcha(email)

    // 从redis读
    storedCaptcha, err := Rdb.Get(ctx, key).Result()
    if err != nil {
        if err == redis.Nil {

            return false, nil
        }

        return false, err
    }

    // 比较验证码(大小写不敏感)
    if strings.EqualFold(storedCaptcha, userInput) {

        // 验证成功后删除 key
        if err := Rdb.Del(ctx, key).Err(); err != nil {

        } else {

        }
        return true, nil
    }

    return false, nil
}

SMTP邮件服务

在common/email/email.go中实现了发送邮件的功能,使用goamil,邮箱用了自己的QQ邮箱

const (
    CodeMsg     = "GopherAI验证码如下(验证码仅限于2分钟有效): "
    UserNameMsg = "GopherAI的账号如下,请保留好,后续可以用账号/邮箱登录 "
)

// 发送验证码
func SendCaptcha(email, code, msg string) error {
    m := gomail.NewMessage()

    // 发件人
    m.SetHeader("From", config.GetConfig().EmailConfig.Email)
    // 收件人
    m.SetHeader("To", email)
    // 主题
    m.SetHeader("Subject", "来自GopherAI的信息")
    // 正文内容(纯文本形式,也可以用 text/html)
    m.SetBody("text/plain", msg+" "+code)

    // 配置 SMTP 服务器和授权码,587:是 SMTP 的明文/STARTTLS 端口号
    d := gomail.NewDialer("smtp.qq.com", 587, config.GetConfig().EmailConfig.Email, config.GetConfig().EmailConfig.Authcode)

    // 发送邮件
    if err := d.DialAndSend(m); err != nil {
        fmt.Printf("DialAndSend err %v:\n", err)
        return err
    }
    fmt.Printf("send mail success\n")
    return nil
}

配置系统实现

最后来介绍本项目的配置系统实现,本质是将toml文件解析为结构体提供给全局使用

采用了单例模式

type MainConfig struct {
    Port    int    `toml:"port"`
    AppName string `toml:"appName"`
    Host    string `toml:"host"`
}

type EmailConfig struct {
    Authcode string `toml:"authcode"`
    Email    string `toml:"email" `
}

type RedisConfig struct {
    RedisPort     int    `toml:"port"`
    RedisDb       int    `toml:"db"`
    RedisHost     string `toml:"host"`
    RedisPassword string `toml:"password"`
}

type MysqlConfig struct {
    MysqlPort         int    `toml:"port"`
    MysqlHost         string `toml:"host"`
    MysqlUser         string `toml:"user"`
    MysqlPassword     string `toml:"password"`
    MysqlDatabaseName string `toml:"databaseName"`
    MysqlCharset      string `toml:"charset"`
}

type JwtConfig struct {
    ExpireDuration int    `toml:"expire_duration"`
    Issuer         string `toml:"issuer"`
    Subject        string `toml:"subject"`
    Key            string `toml:"key"`
}

type Rabbitmq struct {
    RabbitmqPort     int    `toml:"port"`
    RabbitmqHost     string `toml:"host"`
    RabbitmqUsername string `toml:"username"`
    RabbitmqPassword string `toml:"password"`
    RabbitmqVhost    string `toml:"vhost"`
}

type Config struct {
    EmailConfig `toml:"emailConfig"`
    RedisConfig `toml:"redisConfig"`
    MysqlConfig `toml:"mysqlConfig"`
    JwtConfig   `toml:"jwtConfig"`
    MainConfig  `toml:"mainConfig"`
    Rabbitmq    `toml:"rabbitmqConfig"`
}

type RedisKeyConfig struct {
    CaptchaPrefix string
}

var DefaultRedisKeyConfig = RedisKeyConfig{
    CaptchaPrefix: "captcha:%s",
}

var config *Config

// InitConfig 初始化项目配置
func InitConfig() error {
    // 设置配置文件路径(相对于 main.go 所在的目录)
    if _, err := toml.DecodeFile("config/config.toml", config); err != nil {
        log.Fatal(err.Error())
        return err
    }
    return nil
}

func GetConfig() *Config {
    if config == nil {
        config = new(Config)
        _ = InitConfig()
    }
    return config
}

评论

  1. Sankkooos
    Android Firefox 148.0
    10 小时前
    2026-3-24 19:07:50

    封面Lucy好评φ( ̄∇ ̄o)

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇