Hyperledger Fabric 链码开发实战

1、本篇背景

这里假设您已经基本掌握了链码开发及shim包API的相关知识,这里以一个简单的应用场景为例,进行链码的开发。

假设需要用链码开发简单的员工管理应用,要实现以下简单的几个业务需求:
1、可以添加一个部门,部门字段包括部门ID和部门名称;
2、可以为某个部门添加员工,员工字段包括员工ID、员工姓名、员工所属部门、工作岗位;
3、可以根据员工ID进行查询、修改和删除等操作。

2、链码开发

对上面的业务需求理解之后,我们进入链码的开发。先创建一个用于保存本次实验的项目文件夹,这里命名为"my_chaincode01",在该目录下创建一个与文件夹同名的后缀名为".go"链码文件。

2.1 创建结构体

先创建"部门"、"员工"以及智能合约的结构体

// 定义智能合约结构体
type SmartContract struct {
}

/*
 * 定义"部门"结构体
 * 部门字段包括部门ID和部门名称
 */
type Department struct {
  DepartmentID int `json:department_id` // 部门ID
  DepartmentName string `json:department_name`  // 部门名称
}

/*
 * 定义"员工"结构结构体
 * 员工字段包括员工ID、员工姓名、员工所属部门ID、工作岗位
 */
 type Employee struct {
   EmployeeID int `json:employee_id`  // 员工ID
   EmployeeName string `json:employee_name` // 员工姓名
   DepartmentID int `json:department_id` // 部门ID
   Jobs string `json:jobs`  // 工作岗位
 }
2.2 实现Init函数和main函数

这里先使用实现Init函数和main函数,Invoke函数和其他跟业务有关的功能后面一起实现。

 // 在链码初始化过程中调用Init来初始化任何数据
 func (t *SmartContract) Init(stub shim.ChaincodeStubInterface) pb.Response {
   fmt.Println("my_chaincode01 Init")
   return shim.Success(nil)
 }

 // ...后面实现Invoke函数和其他功能
 func (t *SmartContract) Invoke(stub shim.ChaincodeStubInterface) pb.Response {
    // ...
 }
    
 // Go语言的入口是main函数
 func main() {
   err := shim.Start(new(SmartContract))
   if err != nil {
     fmt.Printf("Error creating new Smart Contract: %", err)
   }
 }
2.3 初始化部门

功能是创建一个部门,当然还可以实现删改查等功能,这里暂不处理这些功能。

  // 初始化部门
  func (t *SmartContract) initDepartment(stub shim.ChaincodeStubInterface, args []string) pb.Response {
    if len(args) != 2 {
      return shim.Error("Incorrect number of arguments. Expecting 2 like (departmentId, departmentName)")
    }

    departmentIdAsString := args[0]
    // 转换为int类型
    departmentIdAsInt, err := strconv.Atoi(args[0])
    if err != nil {
      return shim.Error("first argument must be a numeric string")
    }
    departmentName := args[1]
    // 创建"部门"结构体
    department := &Department{departmentIdAsInt, departmentName}

    // 创建联合主键,用多个列组合作为联合主键
    // Fabric是用U+0000来把各个联合主键的字段拼接起来,因为这个字符太特殊,所以很适合,
    departmentIdKey, err := stub.CreateCompositeKey("Department", []string{"department", departmentIdAsString})
    if err != nil {
           return shim.Error(err.Error())
      }

    // 结构体转换为json字符串
    departmentAsBytes, err := json.Marshal(department)
    if err != nil {
      return shim.Error(err.Error())
    }

    // 新增一条"部门"数据
    err = stub.PutState(departmentIdKey, departmentAsBytes)
    if err != nil {
            return shim.Error(err.Error())
      }
    return shim.Success(departmentAsBytes)
 }
2.4 新增员工

因为员工率属于部门,所以,新增员工的时候,需要判断添加的部门是否已经存在

 // 新增员工
 func (t *SmartContract) addEmployee(stub shim.ChaincodeStubInterface, args []string) pb.Response {
   // 将字符串数组类型数据转换为"员工"结构体
   employee, err := translateEmployeeFromArgs(args)
     if err != nil {
        return shim.Error(err.Error())
     }
   fmt.Println("employee:", employee)
     employeeIdAsString := strconv.Itoa(employee.EmployeeID)
   // 检查添加的部门ID是否已经存在,返回所有的部门ID
   departmentIds := queryAllDepartmentIDs(stub)
   fmt.Println("departmentIds:", departmentIds)

   // 是否已经存在该员工
   isExist := false
   if len(departmentIds) > 0 {
     for _, departmentId := range departmentIds {
       // 转换为int类型
       departmentIdAsInt, err := strconv.Atoi(departmentId)
       if err != nil {
             return shim.Error("Department Id argument must be a numeric string")
         }

       if departmentIdAsInt == employee.DepartmentID {
         isExist = true
         break
       }
     }
   }

   if isExist {
     // 读取账本中的数据
     employeeAsBytes, err := stub.GetState(employeeIdAsString)
     if err != nil {
       return shim.Error(err.Error())
     } else if employeeAsBytes != nil {
       fmt.Println("This employee already exists: " + employeeIdAsString)
       return shim.Error("This employee already exists: " + employeeIdAsString)
     }

     // 结构体转换为json字符串
     employeeAsJsonBytes, err := json.Marshal(employee)
     if err != nil {
       return shim.Error(err.Error())
     }
     // 保存到账本中
     err = stub.PutState(employeeIdAsString, employeeAsJsonBytes)
     if err != nil {
       return shim.Error(err.Error())
     }
     return shim.Success(employeeAsJsonBytes)
   } else {
     fmt.Println("department:" + string(employee.DepartmentID) + " does not exist")
         return shim.Error("department:" + string(employee.DepartmentID) +  " does not exist")
   }
 }

其中涉及到将字符串数组转换为"员工"结构体的功能:

 // 将字符串数组类型数据转换为"员工"结构体
 func translateEmployeeFromArgs(args []string) (*Employee, error) {
   if len(args) != 4 {
     return nil, errors.New("Incorrect number of arguments. Expecting 4 like (employeeId, employeeName, departmentId, jobs)")
   }

   // 转换为int类型
   employeeId, err := strconv.Atoi(args[0])
   if err != nil {
         return nil, errors.New("first argument must be a numeric string")
     }
   employeeName := args[1]
   departmentId, err := strconv.Atoi(args[2])
   if err != nil {
         return nil, errors.New("third argument must be a numeric string")
     }
   jobs := args[3]

   employee := &Employee{employeeId, employeeName, departmentId, jobs}
   return employee, nil
 }

而且,涉及到查询所有部门ID的功能:

// 获取所有部门ID
 func queryAllDepartmentIDs(stub shim.ChaincodeStubInterface) []string {
   // 部分复合键的查询GetStateByPartialCompositeKey,一种对Key进行前缀匹配的查询
    compositeKeysIterator, err := stub.GetStateByPartialCompositeKey("Department", []string{"department"})
    if err != nil {
        return nil
    }

    defer compositeKeysIterator.Close()

    departmentIds := make([]string, 0)

    for i := 0; compositeKeysIterator.HasNext(); i++ {
        responseRange, err := compositeKeysIterator.Next()
        if err != nil {
            return nil
        }
    // 拆分复合键SplitCompositeKey,
        _, compositeKeyParts, err := stub.SplitCompositeKey(responseRange.Key)
        if err != nil {
            return nil
        }
        departmentId := compositeKeyParts[1]
        departmentIds = append(departmentIds, departmentId)
    }

    return departmentIds
}
2.5 删除员工

删除员工和查询员工都比较简单:

 // 删除员工
 func (t *SmartContract) deleteEmployee(stub shim.ChaincodeStubInterface, args []string) pb.Response {
   if len(args) < 1 {
        return shim.Error("Incorrect number of arguments. Expecting 1 like (employee_id)")
     }
   employeeIdAsString := args[0]
   employeeAsBytes, err := stub.GetState(employeeIdAsString)
   if err != nil {
     return shim.Error("Failed to get employee info:" + err.Error())
   } else if employeeAsBytes == nil {
     return shim.Error("Employee does not exist")
   }

   err = stub.DelState(employeeIdAsString)
   if err != nil {
     return shim.Error("Failed to delete employee:" + employeeIdAsString + err.Error())
   }
   return shim.Success(nil)
 }
2.6 查询员工
 // 根据员工ID查询员工信息
 func (t *SmartContract) searchEmployeeInfoByID(stub shim.ChaincodeStubInterface, args []string) pb.Response {
   if len(args) < 1 {
     return shim.Error("Incorrect number of arguments. Expecting 1 like (employee_id)")
   }
   employeeIdAsString := args[0]
     employeeAsBytes, err := stub.GetState(employeeIdAsString)
     if err != nil {
         return shim.Error("Failed to get employee info:" + err.Error())
     } else if employeeAsBytes == nil {
         return shim.Error("Employee does not exist")
   }

   fmt.Printf("Search Response:%s\n", string(employeeAsBytes))
     return shim.Success(employeeAsBytes)
 }
2.6 更新员工

更新员工信息之前,需要判断传入的部门ID是否存在,存在则更新。不存在的话,这里的做法是直接返回错误信息。

 // 更新员工信息
 func (t *SmartContract) updateEmployeeInfo(stub shim.ChaincodeStubInterface, args []string) pb.Response {
   // 将字符串数组类型数据转换为"员工"结构体
   employee, err := translateEmployeeFromArgs(args)
   if err != nil {
     return shim.Error(err.Error())
   }

   employeeIdAsString := strconv.Itoa(employee.EmployeeID)
   // 检查添加的部门ID是否存在
   departmentIds := queryAllDepartmentIDs(stub)

   isExist := false
   if len(departmentIds) > 0 {
     for _, departmentId := range departmentIds {
       // 转换为int类型
       departmentIdAsInt, err := strconv.Atoi(departmentId)
       if err != nil {
             return shim.Error("Department Id argument must be a numeric string")
         }

       if departmentIdAsInt == employee.DepartmentID {
         isExist = true
         break
       }
     }
   }

   if isExist {
     /*
     * State DB是一个Key-Value数据库,如果我们指定的Key在数据库中已经存在,那么就是修改操作。
     * 如果Key不存在,那么就是插入操作。
     */
     employeeAsJsonBytes, err := json.Marshal(employee)
     if err != nil {
       return shim.Error(err.Error())
     }
     // 保存到账本中
     err = stub.PutState(employeeIdAsString, employeeAsJsonBytes)
     if err != nil {
       return shim.Error(err.Error())
     }
     return shim.Success(employeeAsJsonBytes)
   } else {
     fmt.Println("department:" + string(employee.DepartmentID) + " does not exist")
         return shim.Error("department:" + string(employee.DepartmentID) + " does not exist")
   }
 }
2.7 实现Invoke函数
 // 在链码每个事务中,Invoke会被调用。
 func (t *SmartContract) Invoke(stub shim.ChaincodeStubInterface) pb.Response {
   fmt.Println("my_chaincode01 Invoke")

   function, args := stub.GetFunctionAndParameters()
   if function == "initDepartment" {
     return t.initDepartment(stub, args)
   } else if function == "addEmployee" {
     return t.addEmployee(stub, args)
   } else if function == "deleteEmployee" {
     return t.deleteEmployee(stub, args)
   } else if function == "searchEmployeeInfoByID" {
     return t.searchEmployeeInfoByID(stub, args)
   } else if function == "updateEmployeeInfo" {
     return t.updateEmployeeInfo(stub, args)
   }

   return shim.Error("Invalid Smart Contract function name.")
 }

3、链码单元测试

开发完链码后,可以利用shim.MockStub来进行单元测试,从而快速地调试和运行,是提高链码开发效率减少Bug的好方法。

于是,我们可以新建一个单元测试文件,一般命名为"链码文件名_test.go",比如这里为"my_chaincode01_test.go",完整代码如下:

package main

import (
    "fmt"
    "testing"
    "github.com/hyperledger/fabric/core/chaincode/shim"
)

func mockInit(t *testing.T, stub *shim.MockStub, args [][]byte) {
    res := stub.MockInit("1", args)
    if res.Status != shim.OK {
        fmt.Println("Init failed", string(res.Message))
        t.FailNow()
    }
}

func initDepartment(t *testing.T, stub *shim.MockStub, args []string) {
    res := stub.MockInvoke("1", [][]byte{[]byte("initDepartment"), []byte(args[0]), []byte(args[1])})

    if res.Status != shim.OK {
        fmt.Println("InitDepartment failed:", args[0], string(res.Message))
        t.FailNow()
    }
}

func addEmployee(t *testing.T, stub *shim.MockStub, args []string) {
    res := stub.MockInvoke("1", [][]byte{[]byte("addEmployee"), []byte(args[0]), []byte(args[1]), []byte(args[2]), []byte(args[3])})

    if res.Status != shim.OK {
        fmt.Println("AddEmployee failed:", args[0], string(res.Message))
        t.FailNow()
    }
}

func deleteEmployee(t *testing.T, stub *shim.MockStub, employeeId string) {
    res := stub.MockInvoke("1", [][]byte{[]byte("deleteEmployee"), []byte(employeeId)})

    if res.Status != shim.OK {
        fmt.Println("DeleteEmployee :", employeeId, ", failed :", string(res.Message))
        t.FailNow()
    }
}

func searchEmployeeInfoByID(t *testing.T, stub *shim.MockStub, employeeId string) {
    res := stub.MockInvoke("1", [][]byte{[]byte("searchEmployeeInfoByID"), []byte(employeeId)})
    if res.Status != shim.OK {
        fmt.Println("SearchEmployeeInfoByID :", employeeId, ", failed :", string(res.Message))
        t.FailNow()
    }
    if res.Payload == nil {
        fmt.Println("SearchEmployeeInfoByID :" , employeeId, " failed to get value")
        t.FailNow()
    }
}

func updateEmployeeInfo(t *testing.T, stub *shim.MockStub, args []string) {
    res := stub.MockInvoke("1", [][]byte{[]byte("updateEmployeeInfo"), []byte(args[0]), []byte(args[1]), []byte(args[2]), []byte(args[3])})

    if res.Status != shim.OK {
        fmt.Println("UpdateEmployeeInfo failed:", args[0], string(res.Message))
        t.FailNow()
    }
}


// 测试"初始化部门"
func TestInitDepartment(t *testing.T) {
    smartContract := new(SmartContract)
    stub := shim.NewMockStub("SmartContract", smartContract)
    mockInit(t, stub, nil)
    initDepartment(t, stub, []string{"1", "department_software"})
    initDepartment(t, stub, []string{"2", "department_test"})
}

// 测试"新增员工",部门ID不存在时创建会失败
func TestAddEmployee(t *testing.T) {
    smartContract := new(SmartContract)
    stub := shim.NewMockStub("SmartContract", smartContract)
    mockInit(t, stub, nil)
    initDepartment(t, stub, []string{"1", "department_software"})
    addEmployee(t, stub, []string{"1", "Wenzil", "1", "Software Engineer"})
    // ID为"2"的部门没有创建,返回错误,所以先注释掉这一行
    // addEmployee(t, stub, []string{"2", "Test", "2", "Test Engineer"})
}

// 测试"查询员工信息"
func TestSearchEmployeeInfoByID(t *testing.T) {
    smartContract := new(SmartContract)
    stub := shim.NewMockStub("SmartContract", smartContract)
    mockInit(t, stub, nil)
    initDepartment(t, stub, []string{"2", "department_test"})
    addEmployee(t, stub, []string{"2", "Test", "2", "Test Engineer"})
    searchEmployeeInfoByID(t, stub, "2")
}

// 测试"删除员工"
func TestDeleteEmployee(t *testing.T) {
    smartContract := new(SmartContract)
    stub := shim.NewMockStub("SmartContract", smartContract)
    mockInit(t, stub, nil)
    initDepartment(t, stub, []string{"3", "department_ui"})
    addEmployee(t, stub, []string{"3", "Han Meimei", "3", "UI Designer"})
    deleteEmployee(t, stub, "3")
}

// 测试更新"员工信息"
func TestUpdateEmployeeInfo(t *testing.T) {
    smartContract := new(SmartContract)
    stub := shim.NewMockStub("SmartContract", smartContract)
    mockInit(t, stub, nil)
    initDepartment(t, stub, []string{"4", "department_blockchain"})
    addEmployee(t, stub, []string{"7", "Li Lei", "4", "Blockchain Designer"})
    updateEmployeeInfo(t, stub, []string{"7", "Li Lei", "4", "Blockchain Senior Designer"})
    searchEmployeeInfoByID(t, stub, "7")
}

4、部署和测试链码

确保您搭建并配置好了Hyperledger Fabric的开发环境,我们把上面创建的链码文件夹复制到"fabric-samples"目录下。

链码保存路径

同时,开启三个终端,确保终端进入到"fabric-samples/chaincode-docker-devmode"目录下。

4.1 终端1 - 开启网络
###删除所有活跃的容器###
docker rm -f $(docker ps -aq)
###清理网络缓存###
docker network prune
###开启网络###
docker-compose -f docker-compose-simple.yaml up
4.2 终端2 - 编译和运行链码
###进入Docker容器cli###
docker exec -it chaincode bash
###进入到链码对应目录###
cd my_chaincode01
###执行单元测试命令###
go test -v my_chaincode01_test.go my_chaincode01.go
单元测试结果

单元测试通过后,继续执行如下命令:

###编译链码###
go build
###启动节点###
CORE_PEER_ADDRESS=peer:7052 CORE_CHAINCODE_ID_NAME=mycc:0 ./my_chaincode01
###如果失败,把"7052"改为"7051"试试看
4.3 终端3 - 调用链码

1、启动Docker cli容器:

docker exec -it chaincode bash

2、安装和实例化链码:

peer chaincode install -p chaincodedev/chaincode/my_chaincode01 -n mycc -v 0
peer chaincode instantiate -n mycc -v 0 -c '{"Args":[]}' -C myc

3、初始化部门:

peer chaincode invoke -n mycc -c '{"Args":["initDepartment","1","department_software"]}' -C myc
2018-06-15 17:31:32.191 UTC [chaincodeCmd] chaincodeInvokeOrQuery -> INFO 063 Chaincode invoke successful. result: status:200 payload:"{\"DepartmentID\":1,\"DepartmentName\":\"department_software\"}" 

4、新增员工:

peer chaincode invoke -n mycc -c '{"Args":["addEmployee","1","Wenzil","1","Software Engineer"]}' -C myc
2018-06-15 17:33:21.046 UTC [chaincodeCmd] chaincodeInvokeOrQuery -> INFO 063 Chaincode invoke successful. result: status:200 payload:"{\"EmployeeID\":1,\"EmployeeName\":\"Wenzil\",\"DepartmentID\":1,\"Jobs\":\"Software Engineer\"}" 
peer chaincode invoke -n mycc -c '{"Args":["addEmployee","2","Li Lei","1","AI Engineer"]}' -C myc
2018-06-15 17:35:12.030 UTC [chaincodeCmd] chaincodeInvokeOrQuery -> INFO 063 Chaincode invoke successful. result: status:200 payload:"{\"EmployeeID\":2,\"EmployeeName\":\"Li Lei\",\"DepartmentID\":1,\"Jobs\":\"AI Engineer\"}" 

5、更新员工:

peer chaincode invoke -n mycc -c '{"Args":["updateEmployeeInfo","2","Li Lei","1","Blockchain Engineer"]}' -C myc
2018-06-15 17:39:25.819 UTC [chaincodeCmd] chaincodeInvokeOrQuery -> INFO 063 Chaincode invoke successful. result: status:200 payload:"{\"EmployeeID\":2,\"EmployeeName\":\"Li Lei\",\"DepartmentID\":1,\"Jobs\":\"Blockchain Engineer\"}" 

6、查询员工:

peer chaincode invoke -n mycc -c '{"Args":["searchEmployeeInfoByID","2"]}' -C myc
2018-06-15 17:40:58.217 UTC [chaincodeCmd] chaincodeInvokeOrQuery -> INFO 063 Chaincode invoke successful. result: status:200 payload:"{\"EmployeeID\":2,\"EmployeeName\":\"Li Lei\",\"DepartmentID\":1,\"Jobs\":\"Blockchain Engineer\"}" 

7、删除员工:

peer chaincode invoke -n mycc -c '{"Args":["deleteEmployee","2"]}' -C myc
2018-06-15 17:41:51.422 UTC [chaincodeCmd] chaincodeInvokeOrQuery -> INFO 063 Chaincode invoke successful. result: status:200 

8、再次查询:

peer chaincode invoke -n mycc -c '{"Args":["searchEmployeeInfoByID","2"]}' -C myc
###直接报错###
Error: Error endorsing invoke: rpc error: code = Unknown desc = chaincode error (status: 500, message: Employee does not exist) - <nil>
......

###改为查询ID为"1"的员工###
peer chaincode invoke -n mycc -c '{"Args":["searchEmployeeInfoByID","1"]}' -C myc
2018-06-15 17:43:56.039 UTC [chaincodeCmd] chaincodeInvokeOrQuery -> INFO 063 Chaincode invoke successful. result: status:200 payload:"{\"EmployeeID\":1,\"EmployeeName\":\"Wenzil\",\"DepartmentID\":1,\"Jobs\":\"Software Engineer\"}"
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容