Go语言 gin框架集成Casbin访问权限控制

1. Casbin是什么?

Casbin是一个强大的、高效的开源访问控制框架,其权限管理机制支持多种访问控制模型。因此Casbin不能做身份验证, 最佳的实践是只负责访问控制

1.1 Casbin的model

Casbin 中, 访问控制模型被抽象为基于 PERM (Policy, Effect, Request, Matcher) 的一个文件,这个文件的具体呈现是一个以 .conf 作为后缀的文件

example :

rbac_model.conf

# Request定义
[request_definition]
r = sub, obj, act

# 策略定义
[policy_definition]
p = sub, obj, act

# 角色定义
[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

# 匹配器定义
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act

对于上面配置文件简单的理解是:


1.1.1 [request_definition]

r = sub, obj, act :定义请求由三部分组成 访问用户的用户 Subject , 访问的资源 Object 访问的动作 Action

1.1.2 [policy_definition]

p = sub, obj, act : 定策略的格式 , 参数的基本意思和定义请求的相同 ,定义好了策略格式,那么对于策略(Policy)的具体描述可以存放在一个以 .csv 作为后缀的文件中

example :

rbac_Policy_example.csv

g, coder, root
g, zhangsan coder
p, root,api/v1/ping,GET
p, coder,api/v1/pong,GET
g, lisi, manager
p, manager, api/v1/user,POST

上面的rbac策略中我们定义了三条策略和三个用户组,我们来看一下这些策略都有啥作用

  1. coder是root的角色
  2. zhangsan是coder的角色
  3. root 可以访问 api/v1/ping 资源 通过GET动作,那么coder , zhangsan也可以访问
  4. coder可以访问 api/v1/pong 资源 通过GET动作,zhangsan也能访问
  5. lisi是manager的角色
  6. manager可以访问 api/v1/user资源通过POST动作,lisi也可以访问
1.1.3 [role_definition]

**g = _, _ ** : 是RBAC角色继承关系的定义 ,此处的 _, _ 表示 前项继承后项角色的权限

1.1.4 [policy_effect]

e = some(where (p.eft == allow)) : 表示任意一条Policy策略满足那么结果就为allow

1.1.5 [matchers]

m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act : 定义了策略匹配者。匹配者是一组表达式。它定义了如何根据请求来匹配策略规则,匹配表达式的写法比较灵活根据具体需求来编写即可.
而此处的表达式意思是 ,检测用户角色 && 检测用户访问的资源 &&检测用户的动作 (&&表示并且关系,当然也有其他逻辑运算符 ||,!等)

1.2 Casbin的Policy

Policy 主要表示访问控制关于角色,资源,行为的具体映射关系这比较好处理,但是这种映射关系怎么存储就值得考虑了

1.2.1 csv 文件存储
访问控制模型 Model 文件 Policy 文件
ACL basic_model.conf basic_policy.csv
具有超级用户的ACL basic_with_root_model.conf basic_policy.csv
没有用户的ACL basic_without_users_model.conf basic_without_users_policy.csv
没有资源的ACL basic_without_resources_model.conf basic_without_resources_policy.csv
RBAC rbac_model.conf rbac_policy.csv
支持资源角色的RBAC rbac_with_resource_roles_model.conf rbac_with_resource_roles_policy.csv
支持域/租户的RBAC rbac_with_domains_model.conf rbac_with_domains_policy.csv
ABAC abac_model.conf
RESTful keymatch_model.conf keymatch_policy.csv
拒绝优先 rbac_with_not_deny_model.conf rbac_with_deny_policy.csv
Allow-and-deny rbac_with_deny_model.conf rbac_with_deny_policy.csv
Priority priority_model.conf priority_policy.csv
1.2.2 适配器存储

casbin的适配器 adapter 可以从存储中加载策略规则,也可将策略规则保存到不同的存储系统中

支持如: MySQL, PostgreSQL, SQL Server, SQLite3,MongoDB,Redis,Cassandra DB等等存储系统

适配器 类型 作者 自动保存 描述
File Adapter (内置) File Casbin For .CSV (Comma-Separated Values) files
Filtered File Adapter (内置) File @faceless-saint For .CSV (Comma-Separated Values) files with policy subset loading support
SQL Adapter SQL @Blank-Xu MySQL, PostgreSQL, SQL Server, SQLite3 are supported in master branch and Oracle is supported in oracle branch by database/sql
Xorm Adapter ORM Casbin MySQL, PostgreSQL, TiDB, SQLite, SQL Server, Oracle are supported by Xorm
Gorm Adapter ORM Casbin MySQL, PostgreSQL, Sqlite3, SQL Server are supported by Gorm
Beego ORM Adapter ORM Casbin MySQL, PostgreSQL, Sqlite3 are supported by Beego ORM
SQLX Adapter ORM @memwey MySQL, PostgreSQL, SQLite, Oracle are supported by SQLX
Sqlx Adapter SQL @Blank-Xu MySQL, PostgreSQL, SQL Server, SQLite3 are supported in master branch and Oracle is supported in oracle branch by sqlx
GF ORM Adapter ORM @vance-liu MySQL, SQLite, PostgreSQL, Oracle, SQL Server are supported by GF ORM
Filtered PostgreSQL Adapter SQL Casbin For PostgreSQL
PostgreSQL Adapter SQL @cychiuae For PostgreSQL
PostgreSQL Adapter (Archived) SQL Going For PostgreSQL
RQLite Adapter SQL EDOMO Systems For RQLite
MongoDB Adapter NoSQL Casbin For MongoDB based on MongoDB driver for Go
MongoDB Adapter NoSQL Titan DC For MongoDB based on MongoDB Go driver
RethinkDB Adapter NoSQL @adityapandey9 For RethinkDB
Cassandra Adapter NoSQL Casbin For Apache Cassandra DB
DynamoDB Adapter NoSQL HOOQ For Amazon DynamoDB
Dynacasbin NoSQL NewbMiao For Amazon DynamoDB
ArangoDB Adapter NoSQL @adamwasila For ArangoDB
Amazon S3 Adapter Cloud Soluto For Minio and Amazon S3
Azure Cosmos DB Adapter Cloud @spacycoder For Microsoft Azure Cosmos DB
GCP Datastore Adapter Cloud LivingPackets For Google Cloud Platform Datastore
GCP Firestore Adapter Cloud @reedom For Google Cloud Platform Firestore
Consul Adapter KV store @ankitm123 For HashiCorp Consul
Redis Adapter KV store Casbin For Redis
Etcd Adapter KV store @sebastianliu For etcd
BoltDB Adapter KV store @speza For Bolt
Bolt Adapter KV store @wirepair For Bolt
BadgerDB Adapter KV store @inits For BadgerDB
Protobuf Adapter Stream Casbin For Google Protocol Buffers
JSON Adapter String Casbin For JSON
String Adapter String @qiangmzsx For String

2. gin集成Casbin实现RESTful接口访问控制

2.1 go mod 构建项目

# 新建个叫做ginCasbin的gomod项目(项目名自定义)
go mod init GinCasbin

2.2 安装依赖包

# 安装依赖包
# 安装gin框架
go get -u github.com/gin-gonic/gin
# Go语言casbin的依赖包
go get github.com/casbin/casbin
# gorm 适配器依赖包
go get github.com/casbin/gorm-adapter
# mysql驱动依赖
go get github.com/go-sql-driver/mysql
# gorm 包
go get github.com/jinzhu/gorm
# 高性能缓存BigCache
go get github.com/allegro/bigcache/v2

2.3 目录规划说明

├─app # 业务目录
│  ├─api  ## 存放api的目录(暂时不用)
│  ├─model ## 存放实体的目录(暂时不用)
│  └─service ## 存放业务代码的目录(暂时不用)
├─config # 存放配置文件的目录
├─middleware # 存放中间件的目录
├─routers # 存放路由的目录
└─utils # 常用工具组件目录
    ├─ACS ## 存放访问控制执行器目录
    ├─APIResponse ##  存放API统一响应函数目录
    ├─Cache ## 缓存工具目录
    └─DB ## 数据连接文件目录
├─go.mod
├─go.sum
├─main.go # 项目入口文件

2.4 项目代码开发

2.4.1 工具组件开发
# 进入utils目录
cd utils

DB/mysql.go

package DB

import (
    "fmt"
    "github.com/jinzhu/gorm"
)
import _ "github.com/go-sql-driver/mysql"

var (
    Mysql *gorm.DB
)

func init() {
    var err error
    dsn := "root:root@(127.0.0.1:3306)/xz_boss?charset=utf8&parseTime=True&loc=Local"
    Mysql, err = gorm.Open("mysql", dsn)
    if err != nil {
        fmt.Println("connect DB error")
        panic(err)
    }
}

ACS/enforcer.go

package ACS

import (
    "GinCasbin/utils/DB"
    "github.com/casbin/casbin"
    "github.com/casbin/gorm-adapter"
)

var Enforcer *casbin.Enforcer

func init() {
    // mysql 适配器
    adapter := gormadapter.NewAdapterByDB(DB.Mysql)
    // 通过mysql适配器新建一个enforcer
    Enforcer = casbin.NewEnforcer("config/keymatch2_model.conf", adapter)
    // 日志记录
    Enforcer.EnableLog(true)
}


APIResponse/response.go

package APIResponse

import "github.com/gin-gonic/gin"

type Response struct {
    Code    int         `json:"code"`
    Message string      `json:"message"`
    Data    interface{} `json:"data"`
}

var C *gin.Context

func Error(message string) {
    if len(message) == 0 {
        message = "fail"
    }
    C.JSON(200, Response{
        Code:    -1,
        Message: message,
        Data:    nil,
    })
}
func Success(data interface{}) {
    C.JSON(200, Response{
        Code:    200,
        Message: "success",
        Data:    data,
    })
}

Cache/big.go

package Cache

import (
    "github.com/allegro/bigcache/v2"
    "time"
)

var GlobalCache *bigcache.BigCache

func init() {
    // 初始化BigCache实例
    GlobalCache, _ = bigcache.NewBigCache(bigcache.DefaultConfig(30 * time.Minute))
}

2.4.2 配置文件

常规项目中配置文件目录中会存放各种配置文件,在这个Demo中仅将casbin的模型文件放在这里

cd ../config

config/keymatch2_model.conf

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = r.sub == p.sub && keyMatch2(r.obj, p.obj) && regexMatch(r.act, p.act)
2.4.3 中间件

此处我们编写的一个基于casbin权限控制的中间件

cd ../middleware

middleware/privilege.go

package middleware

import (
    "GinCasbin/utils/ACS"
    "GinCasbin/utils/APIResponse"
    "GinCasbin/utils/Cache"
    "github.com/gin-gonic/gin"
    "log"
)

func Privilege() gin.HandlerFunc {
    return func(c *gin.Context) {
        APIResponse.C = c
        var userName = c.GetHeader("userName")
        if userName == "" {
            APIResponse.Error("header miss userName")
            c.Abort()
            return
        }
        path := c.Request.URL.Path
        method := c.Request.Method
        cacheName := userName + path + method
        // 从缓存中读取&判断
        entry, err := Cache.GlobalCache.Get(cacheName)
        if err == nil && entry != nil {
            if string(entry) == "true" {
                c.Next()
            } else {
                APIResponse.Error("access denied")
                c.Abort()
                return
            }
        } else {
            // 从数据库中读取&判断
            //记录日志
            ACS.Enforcer.EnableLog(true)
            // 加载策略规则
            err := ACS.Enforcer.LoadPolicy()
            if err != nil {
                log.Println("loadPolicy error")
                panic(err)
            }
            // 验证策略规则
            result, err := ACS.Enforcer.EnforceSafe(userName, path, method)
            if err != nil {
                APIResponse.Error("No permission found")
                c.Abort()
                return
            }
            if !result {
                // 添加到缓存中
                Cache.GlobalCache.Set(cacheName, []byte("false"))
                APIResponse.Error("access denied")
                c.Abort()
                return
            } else {
                Cache.GlobalCache.Set(cacheName, []byte("true"))
            }
            c.Next()
        }
    }
}

2.4.4 路由文件
cd ../routers

routers/route.go

package routers

import (
    "GinCasbin/middleware"
    "GinCasbin/utils/ACS"
    "GinCasbin/utils/APIResponse"
    "GinCasbin/utils/Cache"
    "github.com/gin-gonic/gin"
)

var (
    R *gin.Engine
)

func init() {
    R = gin.Default()
    R.NoRoute(func(c *gin.Context) {
        c.JSON(400, gin.H{"code": 400, "message": "Bad Request"})
    })
    api()
}
func api() {
    auth := R.Group("/api")
    {
        // 模拟添加一条Policy策略
        auth.POST("acs", func(c *gin.Context) {
            APIResponse.C = c
            subject := "tom"
            object := "/api/routers"
            action := "POST"
            cacheName := subject + object + action
            result := ACS.Enforcer.AddPolicy(subject, object, action)
            if result {
                // 清除缓存
                _ = Cache.GlobalCache.Delete(cacheName)
                APIResponse.Success("add success")
            } else {
                APIResponse.Error("add fail")
            }
        })
        // 模拟删除一条Policy策略
        auth.DELETE("acs/:id", func(context *gin.Context) {
            APIResponse.C = context
            result := ACS.Enforcer.RemovePolicy("tom", "/api/routers", "POST")
            if result {
                // 清除缓存 代码省略
                APIResponse.Success("delete Policy success")
            } else {
                APIResponse.Error("delete Policy fail")
            }
        })
        // 获取路由列表
        auth.POST("/routers", middleware.Privilege(), func(c *gin.Context) {
            type data struct {
                Method string `json:"method"`
                Path   string `json:"path"`
            }
            var datas []data
            routers := R.Routes()
            for _, v := range routers {
                var temp data
                temp.Method = v.Method
                temp.Path = v.Path
                datas = append(datas, temp)
            }
            APIResponse.C = c
            APIResponse.Success(datas)
            return
        })
    }
    // 定义路由组
    user := R.Group("/api/v1")
    // 使用访问控制中间件
    user.Use(middleware.Privilege())
    {
        user.POST("user", func(c *gin.Context) {
            c.JSON(200, gin.H{"code": 200, "message": "user add success"})
        })
        user.DELETE("user/:id", func(c *gin.Context) {
            id := c.Param("id")
            c.JSON(200, gin.H{"code": 200, "message": "user delete success " + id})
        })
        user.PUT("user/:id", func(c *gin.Context) {
            id := c.Param("id")
            c.JSON(200, gin.H{"code": 200, "message": "user update success " + id})
        })
        user.GET("user/:id", func(c *gin.Context) {
            id := c.Param("id")
            c.JSON(200, gin.H{"code": 200, "message": "user Get success " + id})
        })
    }
}

2.4.5 项目入口文件
cd ..

main.go

package main

import (
    . "GinCasbin/routers"
)

func main() {
    R.Run()
}

2.5 测试访问策略

2.5.1 启动项目
# 运行项目
go run main.go
# gin框架在debug模式下的输出
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /api/acs                  --> GinCasbin/routers.api.func1 (3 handlers)
[GIN-debug] DELETE /api/acs/:id              --> GinCasbin/routers.api.func2 (3 handlers)
[GIN-debug] POST   /api/routers              --> GinCasbin/routers.api.func3 (4 handlers)
[GIN-debug] POST   /api/v1/user              --> GinCasbin/routers.api.func4 (4 handlers)
[GIN-debug] DELETE /api/v1/user/:id          --> GinCasbin/routers.api.func5 (4 handlers)
[GIN-debug] PUT    /api/v1/user/:id          --> GinCasbin/routers.api.func6 (4 handlers)
[GIN-debug] GET    /api/v1/user/:id          --> GinCasbin/routers.api.func7 (4 handlers)
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080

2.5.2 测试casbin访问控制

新开启一个命令行终端

# 访问接口
# 参数缺失
curl -X POST http://127.0.0.1:8080/api/routers
{"code":-1,"message":"header miss userName","data":null}


# 无访问权限
curl -X POST -H "userName:tom" http://127.0.0.1:8080/api/routers
{"code":-1,"message":"access denied","data":null}


# 添加一条规则(代码中是模拟数据)
curl -X POST http://127.0.0.1:8080/api/acs
{"code":200,"message":"success","data":"add success"}

# 再次访问(有访问权限,可以访问)
curl -X POST -H "userName:tom" http://127.0.0.1:8080/api/routers
{
    "code":200,
    "message":"success",
    "data":[
        {
            "method":"POST",
            "path":"/api/acs"
        },
        {
            "method":"POST",
            "path":"/api/routers"
        },
        {
            "method":"POST",
            "path":"/api/v1/user"
        },
        {
            "method":"DELETE",
            "path":"/api/acs/:id"
        },
        {
            "method":"DELETE",
            "path":"/api/v1/user/:id"
        },
        {
            "method":"PUT",
            "path":"/api/v1/user/:id"
        },
        {
            "method":"GET",
            "path":"/api/v1/user/:id"
        }
    ]
}

# 直接向数据库添加几条Policy策略
INSERT INTO `xz_boss`.`casbin_rule` (`p_type`, `v0`, `v1`, `v2`, `v3`, `v4`, `v5`) VALUES ('p', 'admin', '/api/v1/user', 'POST', NULL, NULL, NULL);
INSERT INTO `xz_boss`.`casbin_rule` (`p_type`, `v0`, `v1`, `v2`, `v3`, `v4`, `v5`) VALUES ('p', 'admin', '/api/v1/user/:id', 'GET', NULL, NULL, NULL);
INSERT INTO `xz_boss`.`casbin_rule` (`p_type`, `v0`, `v1`, `v2`, `v3`, `v4`, `v5`) VALUES ('p', 'admin', '/api/v1/user/:id', 'PUT', NULL, NULL, NULL);

#再测试
## 添加接口
curl -X POST -H "userName:admin" http://127.0.0.1:8080/api/v1/user
{"code":200,"message":"user add success"}
## 查询接口
curl -X GET -H "userName:admin" http://127.0.0.1:8080/api/v1/user/99
{"code":200,"message":"user Get success 99"}
## 更新接口
curl -X PUT -H "userName:admin" http://127.0.0.1:8080/api/v1/user/199
{"code":200,"message":"user update success 199"}
## 删除接口(没有分配访问权限)
curl -X DELETE -H "userName:admin" http://127.0.0.1:8080/api/v1/user/299
{"code":-1,"message":"access denied","data":null}

2.6 其他

casbin的一些适配器有自动保存功能而另外一些则没有,有自动保存功能的适配器会在连接数据的时候自动创建一张表用来保存Policy策略数据(替代存储Policy的csv文件)

上述 Demo 的SQL文件如下(该表是gorm适配器自动创建的)

casbin_rule.sql

-- ----------------------------
-- Table structure for casbin_rule
-- ----------------------------
DROP TABLE IF EXISTS `casbin_rule`;
CREATE TABLE `casbin_rule` (
  `p_type` varchar(100) DEFAULT NULL,
  `v0` varchar(100) DEFAULT NULL,
  `v1` varchar(100) DEFAULT NULL,
  `v2` varchar(100) DEFAULT NULL,
  `v3` varchar(100) DEFAULT NULL,
  `v4` varchar(100) DEFAULT NULL,
  `v5` varchar(100) DEFAULT NULL
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

-- ----------------------------
-- Records of casbin_rule
-- ----------------------------
INSERT INTO `casbin_rule` VALUES ('p', 'zhangsan', '/api/v1/ping', 'GET', null, null, null);
INSERT INTO `casbin_rule` VALUES ('p', 'coder', '/api/v2/user/:id', 'GET', null, null, null);
INSERT INTO `casbin_rule` VALUES ('p', 'coder', '/api/v2/routers', 'GET', null, null, null);
INSERT INTO `casbin_rule` VALUES ('p', 'admin', '/api/v1/user', 'POST', null, null, null);
INSERT INTO `casbin_rule` VALUES ('p', 'admin', '/api/v1/user/:id', 'GET', null, null, null);
INSERT INTO `casbin_rule` VALUES ('p', 'admin', '/api/v1/user/:id', 'PUT', null, null, null);
INSERT INTO `casbin_rule` VALUES ('p', 'tom', '/api/routers', 'POST', '', '', '');

参考资料

- [1] casbin

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342