feat: publish Dever core with project scaffolding and versioned AI skills
This commit is contained in:
1 parent
70fa73cdc2
commit
ac458ffa7f
960 files changed
+221319
-1163
No files matched your search
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"runtime": {
|
||||
"mode": "all",
|
||||
"shutdown_ms": 30000
|
||||
},
|
||||
"job": {
|
||||
"workers": 1,
|
||||
"poll_ms": 1000,
|
||||
"lease_ms": 60000,
|
||||
"retry_base_ms": 1000,
|
||||
"retry_max_ms": 300000
|
||||
},
|
||||
"http": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8080
|
||||
},
|
||||
"database": {
|
||||
"default": {
|
||||
"type": "sqlite",
|
||||
"path": "data/db/platform.db",
|
||||
"tenant_directory": "data/tenants",
|
||||
"max_connections": 4,
|
||||
"max_page_size": 100
|
||||
}
|
||||
},
|
||||
"tenant": {
|
||||
"database": "default",
|
||||
"max_pools": 32,
|
||||
"idle_timeout_ms": 300000
|
||||
},
|
||||
"auth": {
|
||||
"providers": {
|
||||
"session": {
|
||||
"verify": "user.account.verify",
|
||||
"jwtSecret": "replace-this-example-secret-before-deployment-2026",
|
||||
"ttlSeconds": 3600,
|
||||
"cookie": "cms_session"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"admin": {
|
||||
"path": "admin",
|
||||
"auth": "session",
|
||||
"origin": "https://cms.example.com"
|
||||
},
|
||||
"front": {
|
||||
"path": "front",
|
||||
"auth": "session",
|
||||
"origin": "https://cms.example.com"
|
||||
}
|
||||
},
|
||||
"log": {
|
||||
"level": "info"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# 操作审计应用
|
||||
|
||||
审计应用写入最小化操作摘要。
|
||||
|
||||
- 包:`audit.operation.app`
|
||||
- 公开类型:无
|
||||
- 公开方法:
|
||||
- `record`
|
||||
- 使用:
|
||||
- `audit.operation.record(kind, resource, resource_id)`
|
||||
|
||||
## 记录操作
|
||||
|
||||
保存操作类型、资源和资源标识。
|
||||
|
||||
- 函数:`record`
|
||||
- 输入:
|
||||
- `kind: audit.operation.model.OperationKind`:操作类型
|
||||
- `resource: Text`:资源类型
|
||||
- `resource_id: Text`:资源 ID
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
record(kind: model.OperationKind, resource: Text, resource_id: Text) () {
|
||||
stored = model.create(
|
||||
{
|
||||
kind = kind
|
||||
resource = resource
|
||||
resource_id = resource_id
|
||||
}
|
||||
)
|
||||
discarded = stored
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
# 操作审计模型
|
||||
|
||||
审计记录保存敏感操作摘要,不保存密码、Token 或请求全文。
|
||||
|
||||
- 包:`audit.operation.model`
|
||||
- 公开类型:
|
||||
- `OperationKind`
|
||||
- `Operation`
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 操作类型
|
||||
|
||||
审计操作分类。
|
||||
|
||||
- 类型:`OperationKind`
|
||||
- 分支:
|
||||
- `Login`:登录
|
||||
- `Logout`:登出
|
||||
- `Publish`:发布
|
||||
- `Upload`:上传
|
||||
- `Access`:授权
|
||||
|
||||
```dever
|
||||
type OperationKind {
|
||||
Login = "登录"
|
||||
Logout = "登出"
|
||||
Publish = "发布"
|
||||
Upload = "上传"
|
||||
Access = "授权"
|
||||
}
|
||||
```
|
||||
|
||||
## 操作记录
|
||||
|
||||
审计记录只保存资源摘要。
|
||||
|
||||
- 类型:`Operation`
|
||||
- 字段:
|
||||
- `actor_id: user.account.model.principal.id?`:操作者
|
||||
- `kind: OperationKind`:操作类型
|
||||
- `resource: Text(1, 120)`:资源类型
|
||||
- `resource_id: Text(1, 120)`:资源 ID
|
||||
|
||||
```dever
|
||||
type Operation {
|
||||
actor_id: user.account.model.principal.id?
|
||||
kind: OperationKind index
|
||||
resource: Text(1, 120)
|
||||
resource_id: Text(1, 120)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
# 后台分类接口
|
||||
|
||||
后台分类接口创建和停用分类。
|
||||
|
||||
- 包:`content.category.api.admin.manage`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 创建分类
|
||||
|
||||
创建分类委托给应用。
|
||||
|
||||
- 声明:`post create`
|
||||
|
||||
```dever
|
||||
post create = app.create
|
||||
```
|
||||
|
||||
## 停用分类
|
||||
|
||||
停用分类委托给应用。
|
||||
|
||||
- 声明:`delete disable`
|
||||
|
||||
```dever
|
||||
delete disable = app.disable
|
||||
```
|
||||
@@ -0,0 +1,105 @@
|
||||
# 内容分类应用
|
||||
|
||||
分类应用负责创建和停用分类。
|
||||
|
||||
- 包:`content.category.app`
|
||||
- 公开类型:
|
||||
- `CategoryView`
|
||||
- 公开方法:
|
||||
- `create`
|
||||
- `disable`
|
||||
- `require_active`
|
||||
- 使用:
|
||||
- `content.category.create(name)`
|
||||
- `content.category.disable(id)`
|
||||
- `content.category.require_active(id)`
|
||||
|
||||
## 分类视图
|
||||
|
||||
返回分类公开字段。
|
||||
|
||||
- 类型:`CategoryView`
|
||||
- 字段:
|
||||
- `id: model.id`:记录标识
|
||||
- `name: Text`:该声明的业务值
|
||||
- `slug: Text`:规范化标识
|
||||
- `status: Text`:业务状态
|
||||
|
||||
```dever
|
||||
type CategoryView {
|
||||
id: model.id
|
||||
name: Text
|
||||
slug: Text
|
||||
status: Text
|
||||
}
|
||||
```
|
||||
|
||||
## 创建分类
|
||||
|
||||
创建规范化分类。
|
||||
|
||||
- 函数:`create`
|
||||
- 输入:
|
||||
- `name: Text`:该声明的业务值
|
||||
- 输出:
|
||||
- `category: CategoryView`:该声明的业务值
|
||||
|
||||
```dever
|
||||
create(name: Text) (category: CategoryView) {
|
||||
normalized = domain.normalize_name(name)
|
||||
domain.require_name(normalized)
|
||||
stored = model.create(
|
||||
{
|
||||
name = normalized
|
||||
slug = domain.normalize_slug(normalized)
|
||||
}
|
||||
)
|
||||
category = CategoryView {
|
||||
id = stored.id
|
||||
name = stored.name
|
||||
slug = stored.slug
|
||||
status = "active"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 停用分类
|
||||
|
||||
停用已有分类。
|
||||
|
||||
- 函数:`disable`
|
||||
- 输入:
|
||||
- `id: content.category.model.id`:记录标识
|
||||
- 输出:
|
||||
- `ok: Bool`:该声明的业务值
|
||||
|
||||
```dever
|
||||
disable(id: model.id) (ok: Bool) {
|
||||
changed = model.update(
|
||||
id,
|
||||
{
|
||||
status = model.CategoryStatus.Disabled
|
||||
}
|
||||
)
|
||||
discarded = changed
|
||||
ok = true
|
||||
}
|
||||
```
|
||||
|
||||
## require_active
|
||||
|
||||
分类应用负责创建和停用分类。
|
||||
|
||||
- 函数:`require_active`
|
||||
- 输入:
|
||||
- `id: content.category.model.id?`:记录标识
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_active(id: model.id) () {
|
||||
stored = model.get(id)
|
||||
domain.require_active(stored.status == model.CategoryStatus.Active)
|
||||
}
|
||||
|
||||
require_active(id: null) () {}
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# 内容分类规则
|
||||
|
||||
分类名称和标识在领域规则中统一规范化。
|
||||
|
||||
- 包:`content.category.domain`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 规范名称
|
||||
|
||||
去除分类名称两端空白。
|
||||
|
||||
- 函数:`normalize_name`
|
||||
- 输入:
|
||||
- `value: Text`:该声明的业务值
|
||||
- 输出:
|
||||
- `name: Text`:该声明的业务值
|
||||
|
||||
```dever
|
||||
normalize_name(value: Text) (name: Text) pure {
|
||||
name = text.trim(value)
|
||||
}
|
||||
```
|
||||
|
||||
## 规范标识
|
||||
|
||||
统一分类标识大小写。
|
||||
|
||||
- 函数:`normalize_slug`
|
||||
- 输入:
|
||||
- `value: Text`:该声明的业务值
|
||||
- 输出:
|
||||
- `slug: Text`:规范化标识
|
||||
|
||||
```dever
|
||||
normalize_slug(value: Text) (slug: Text) pure {
|
||||
slug = text.lower(text.trim(value))
|
||||
}
|
||||
```
|
||||
|
||||
## 检查名称
|
||||
|
||||
拒绝空分类名称。
|
||||
|
||||
- 函数:`require_name`
|
||||
- 输入:
|
||||
- `value: Text`:该声明的业务值
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_name(value: Text) () pure {
|
||||
check_name(value != "")
|
||||
}
|
||||
```
|
||||
|
||||
## 检查名称结果
|
||||
|
||||
有效名称继续执行,无效名称返回标准错误。
|
||||
|
||||
- 函数:`check_name`
|
||||
- 输入:
|
||||
- `value: Bool`:该声明的业务值
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
check_name(value: true) () pure {}
|
||||
|
||||
check_name(value: false) () pure {
|
||||
fail(dever.api.Error.Invalid)
|
||||
}
|
||||
```
|
||||
|
||||
## require_active
|
||||
|
||||
分类名称和标识在领域规则中统一规范化。
|
||||
|
||||
- 函数:`require_active`
|
||||
- 输入:
|
||||
- `active: Bool`:该声明的业务值
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_active(active: true) () pure {}
|
||||
|
||||
require_active(active: false) () pure {
|
||||
fail(dever.api.Error.Conflict)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
# 内容分类模型
|
||||
|
||||
内容分类保存标题、标识和状态。
|
||||
|
||||
- 包:`content.category.model`
|
||||
- 公开类型:
|
||||
- `CategoryStatus`
|
||||
- `Category`
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 分类状态
|
||||
|
||||
状态枚举。
|
||||
|
||||
- 类型:`CategoryStatus`
|
||||
- 分支:
|
||||
- `Active`:启用
|
||||
- `Disabled`:禁用
|
||||
|
||||
```dever
|
||||
type CategoryStatus {
|
||||
Active = "启用"
|
||||
Disabled = "禁用"
|
||||
}
|
||||
```
|
||||
|
||||
## 分类
|
||||
|
||||
分类记录。
|
||||
|
||||
- 类型:`Category`
|
||||
- 字段:
|
||||
- `name: Text(1, 80)`:名称
|
||||
- `slug: Text(1, 100)`:标识
|
||||
- `status: CategoryStatus`:状态
|
||||
|
||||
```dever
|
||||
type Category {
|
||||
name: Text(1, 80)
|
||||
slug: Text(1, 100) unique
|
||||
status: CategoryStatus default CategoryStatus.Active index
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
# 后台修订接口
|
||||
|
||||
后台修订接口创建文章修订记录。
|
||||
|
||||
- 包:`content.revision.api.admin.manage`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 创建修订
|
||||
|
||||
创建修订委托给应用。
|
||||
|
||||
- 声明:`post create`
|
||||
|
||||
```dever
|
||||
post create = app.create
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# 内容修订应用
|
||||
|
||||
修订应用创建文章历史版本并返回公开视图。
|
||||
|
||||
- 包:`content.revision.app`
|
||||
- 公开类型:
|
||||
- `RevisionView`
|
||||
- 公开方法:
|
||||
- `create`
|
||||
- 使用:
|
||||
- `content.revision.create(selected_article_id, expected_version, title, body, category_id, asset_id)`
|
||||
|
||||
## 修订视图
|
||||
|
||||
不暴露存储实现细节的修订结果。
|
||||
|
||||
- 类型:`RevisionView`
|
||||
- 字段:
|
||||
- `id: model.id`:记录标识
|
||||
- `article_id: news.article.model.id`:文章标识
|
||||
- `version: Int`:当前编辑版本
|
||||
- `title: Text`:文章标题
|
||||
- `body: Text`:正文
|
||||
- `status: Text`:业务状态
|
||||
|
||||
```dever
|
||||
type RevisionView {
|
||||
id: model.id
|
||||
article_id: news.article.model.id
|
||||
version: Int
|
||||
title: Text
|
||||
body: Text
|
||||
status: Text
|
||||
}
|
||||
```
|
||||
|
||||
## 创建修订
|
||||
|
||||
按文章已有版本数生成下一个版本。
|
||||
|
||||
- 函数:`create`
|
||||
- 输入:
|
||||
- `selected_article_id: news.article.model.id`:待编辑文章标识
|
||||
- `expected_version: Int`:调用者读取的编辑版本
|
||||
- `title: Text`:文章标题
|
||||
- `body: Text`:正文
|
||||
- `category_id: content.category.model.id?`:可选分类引用
|
||||
- `asset_id: media.asset.model.id?`:可选媒体引用
|
||||
- 输出:
|
||||
- `revision: RevisionView`:该声明的业务值
|
||||
|
||||
```dever
|
||||
transaction create(
|
||||
selected_article_id: news.article.model.id,
|
||||
expected_version: Int,
|
||||
title: Text,
|
||||
body: Text,
|
||||
category_id: content.category.model.id?,
|
||||
asset_id: media.asset.model.id?
|
||||
) (revision: RevisionView) {
|
||||
edited = news.article.edit(
|
||||
selected_article_id,
|
||||
expected_version,
|
||||
title,
|
||||
body,
|
||||
category_id,
|
||||
asset_id
|
||||
)
|
||||
stored = model.create(
|
||||
{
|
||||
article_id = selected_article_id
|
||||
version = edited.version
|
||||
title = edited.title
|
||||
body = edited.body
|
||||
category_id = edited.category_id
|
||||
asset_id = edited.asset_id
|
||||
status = model.RevisionStatus.Draft
|
||||
}
|
||||
)
|
||||
revision = RevisionView {
|
||||
id = stored.id
|
||||
article_id = stored.article_id
|
||||
version = stored.version
|
||||
title = stored.title
|
||||
body = stored.body
|
||||
status = "draft"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,62 @@
|
||||
# 内容修订模型
|
||||
|
||||
修订记录保存文章历史内容和版本。
|
||||
|
||||
- 包:`content.revision.model`
|
||||
- 公开类型:
|
||||
- `RevisionStatus`
|
||||
- `Revision`
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 修订状态
|
||||
|
||||
修订状态枚举。
|
||||
|
||||
- 类型:`RevisionStatus`
|
||||
- 分支:
|
||||
- `Draft`:该声明的业务值
|
||||
- `Published`:该声明的业务值
|
||||
|
||||
```dever
|
||||
type RevisionStatus {
|
||||
Draft = "草稿"
|
||||
Published = "已发布"
|
||||
}
|
||||
```
|
||||
|
||||
## 修订记录
|
||||
|
||||
文章的一个历史版本。
|
||||
|
||||
- 类型:`Revision`
|
||||
- 字段:
|
||||
- `article_id: news.article.model.id`:文章标识
|
||||
- `version: Int`:当前编辑版本
|
||||
- `title: Text(1, 160)`:文章标题
|
||||
- `body: Text`:正文
|
||||
- `category_id: content.category.model.id?`:可选分类引用
|
||||
- `asset_id: media.asset.model.id?`:可选媒体引用
|
||||
- `status: RevisionStatus`:业务状态
|
||||
|
||||
```dever
|
||||
type Revision {
|
||||
article_id: news.article.model.id index
|
||||
version: Int
|
||||
title: Text(1, 160)
|
||||
body: Text
|
||||
category_id: content.category.model.id?
|
||||
asset_id: media.asset.model.id?
|
||||
status: RevisionStatus default RevisionStatus.Draft index
|
||||
}
|
||||
```
|
||||
|
||||
## 版本唯一约束
|
||||
|
||||
同一文章的版本号唯一。
|
||||
|
||||
- 声明:`unique(article_id, version)`
|
||||
|
||||
```dever
|
||||
unique(article_id, version)
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# 媒体本地适配器
|
||||
|
||||
本地适配器使用受控 data/upload 路径保存上传内容。
|
||||
|
||||
- 包:`media.asset.adapter`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 本地存储
|
||||
|
||||
适配器实现媒体存储端口。
|
||||
|
||||
- 函数:`port.store`
|
||||
- 输入:
|
||||
- `file: Upload`:上传文件
|
||||
- 输出:
|
||||
- `key: Uuid`:存储键
|
||||
|
||||
```dever
|
||||
port.store(file: Upload) (key: Uuid) {
|
||||
key = dever.storage.put(file)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
# 后台媒体接口
|
||||
|
||||
后台媒体接口只开放上传动作。
|
||||
|
||||
- 包:`media.asset.api.admin.manage`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 上传媒体
|
||||
|
||||
上传请求委托给媒体应用。
|
||||
|
||||
- 声明:`post upload`
|
||||
|
||||
```dever
|
||||
post upload = app.store
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
# 媒体资源应用
|
||||
|
||||
媒体应用通过 Storage Port 接收上传并保存元数据。
|
||||
|
||||
- 包:`media.asset.app`
|
||||
- 公开类型:
|
||||
- `StoreResult`
|
||||
- 公开方法:
|
||||
- `store`
|
||||
- `require_ready`
|
||||
- `ready`
|
||||
- 使用:
|
||||
- `media.asset.store(file)`
|
||||
- `media.asset.require_ready(id)`
|
||||
- `media.asset.ready(available)`
|
||||
|
||||
## 保存结果
|
||||
|
||||
返回资源公开元数据。
|
||||
|
||||
- 类型:`StoreResult`
|
||||
- 字段:
|
||||
- `id: model.id`:记录标识
|
||||
- `storage_key: Uuid`:该声明的业务值
|
||||
- `filename: Text`:该声明的业务值
|
||||
- `content_type: Text`:该声明的业务值
|
||||
- `size: Int`:受限页大小
|
||||
|
||||
```dever
|
||||
type StoreResult {
|
||||
id: model.id
|
||||
storage_key: Uuid
|
||||
filename: Text
|
||||
content_type: Text
|
||||
size: Int
|
||||
}
|
||||
```
|
||||
|
||||
## 保存上传
|
||||
|
||||
文件内容只能通过 Port 交给 Adapter。
|
||||
|
||||
- 函数:`store`
|
||||
- 输入:
|
||||
- `file: Upload`:该声明的业务值
|
||||
- 输出:
|
||||
- `result: StoreResult`:该声明的业务值
|
||||
|
||||
```dever
|
||||
store(file: Upload) (result: StoreResult) {
|
||||
filename = dever.api.upload_filename(file)
|
||||
content_type = dever.api.upload_content_type(file)
|
||||
size = dever.api.upload_size(file)
|
||||
key = port.store(file)
|
||||
asset = model.create(
|
||||
{
|
||||
storage_key = key
|
||||
filename = filename
|
||||
content_type = content_type
|
||||
size = size
|
||||
}
|
||||
)
|
||||
audit.operation.record(audit.operation.model.OperationKind.Upload, "asset", filename)
|
||||
result = StoreResult {
|
||||
id = asset.id
|
||||
storage_key = asset.storage_key
|
||||
filename = asset.filename
|
||||
content_type = asset.content_type
|
||||
size = asset.size
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## require_ready
|
||||
|
||||
媒体应用通过 Storage Port 接收上传并保存元数据。
|
||||
|
||||
- 函数:`require_ready`
|
||||
- 输入:
|
||||
- `id: media.asset.model.id?`:记录标识
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_ready(id: model.id) () {
|
||||
stored = model.get(id)
|
||||
ready(stored.status == model.AssetStatus.Ready)
|
||||
}
|
||||
|
||||
require_ready(id: null) () {}
|
||||
```
|
||||
|
||||
## ready
|
||||
|
||||
媒体应用通过 Storage Port 接收上传并保存元数据。
|
||||
|
||||
- 函数:`ready`
|
||||
- 输入:
|
||||
- `available: Bool`:该声明的业务值
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
ready(available: true) () {}
|
||||
|
||||
ready(available: false) () {
|
||||
fail(dever.api.Error.Conflict)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
# 媒体资源模型
|
||||
|
||||
媒体模型只保存受控存储键和文件元数据。
|
||||
|
||||
- 包:`media.asset.model`
|
||||
- 公开类型:
|
||||
- `AssetStatus`
|
||||
- `Asset`
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 资源状态
|
||||
|
||||
资源生命周期状态。
|
||||
|
||||
- 类型:`AssetStatus`
|
||||
- 分支:
|
||||
- `Ready`:就绪
|
||||
- `Removed`:已删除
|
||||
|
||||
```dever
|
||||
type AssetStatus {
|
||||
Ready = "就绪"
|
||||
Removed = "已删除"
|
||||
}
|
||||
```
|
||||
|
||||
## 资源记录
|
||||
|
||||
资源元数据不包含上传内容本身。
|
||||
|
||||
- 类型:`Asset`
|
||||
- 字段:
|
||||
- `storage_key: Uuid`:存储键
|
||||
- `filename: Text(1, 255)`:文件名
|
||||
- `content_type: Text(1, 160)`:内容类型
|
||||
- `size: Int`:大小
|
||||
- `status: AssetStatus`:状态
|
||||
|
||||
```dever
|
||||
type Asset {
|
||||
storage_key: Uuid unique
|
||||
filename: Text(1, 255)
|
||||
content_type: Text(1, 160)
|
||||
size: Int
|
||||
status: AssetStatus default AssetStatus.Ready index
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# 媒体存储端口
|
||||
|
||||
媒体应用通过端口隔离存储实现。
|
||||
|
||||
- 包:`media.asset.port`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 保存上传
|
||||
|
||||
端口只声明上传内容到存储的契约。
|
||||
|
||||
- 函数:`store`
|
||||
- 输入:
|
||||
- `file: Upload`:上传文件
|
||||
- 输出:
|
||||
- `key: Uuid`:存储键
|
||||
- 允许失败:`dever.storage.PutResult`
|
||||
|
||||
```dever
|
||||
store(file: Upload) (key: Uuid) fails dever.storage.PutResult
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
# 后台文章接口
|
||||
|
||||
普通管理使用 Model REST,发布保留为业务动作。
|
||||
|
||||
- 包:`news.article.api.admin.manage`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## get list
|
||||
|
||||
普通管理使用 Model REST,发布保留为业务动作。
|
||||
|
||||
- 声明:`get list`
|
||||
|
||||
```dever
|
||||
get list = app.list
|
||||
```
|
||||
|
||||
## get detail
|
||||
|
||||
普通管理使用 Model REST,发布保留为业务动作。
|
||||
|
||||
- 声明:`get detail`
|
||||
|
||||
```dever
|
||||
get detail = app.detail
|
||||
```
|
||||
|
||||
## post create
|
||||
|
||||
普通管理使用 Model REST,发布保留为业务动作。
|
||||
|
||||
- 声明:`post create`
|
||||
|
||||
```dever
|
||||
post create = app.create
|
||||
```
|
||||
|
||||
## delete remove
|
||||
|
||||
普通管理使用 Model REST,发布保留为业务动作。
|
||||
|
||||
- 声明:`delete remove`
|
||||
|
||||
```dever
|
||||
delete remove = app.remove
|
||||
```
|
||||
|
||||
## 发布文章
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 声明:`post publish`
|
||||
|
||||
```dever
|
||||
post publish = app.publish
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
# 前台文章接口
|
||||
|
||||
前台只查询已发布内容,不开放自动 REST 写路由。
|
||||
|
||||
- 包:`news.article.api.front.browse`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 文章列表
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 声明:`get list`
|
||||
|
||||
```dever
|
||||
get list = app.published
|
||||
```
|
||||
|
||||
## 文章详情
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 声明:`get detail`
|
||||
|
||||
```dever
|
||||
get detail = app.published_detail
|
||||
```
|
||||
@@ -0,0 +1,244 @@
|
||||
# news.article.app.editing
|
||||
|
||||
文章创建、所有权查询与带版本的编辑;所有写入校验分类和媒体引用。
|
||||
|
||||
- 包:`news.article.app.editing`
|
||||
- 公开类型:
|
||||
- `ArticleView`
|
||||
- `ArticlePage`
|
||||
- 公开方法:
|
||||
- `create`
|
||||
- `list`
|
||||
- `detail`
|
||||
- `remove`
|
||||
- `edit`
|
||||
- `require_schedulable`
|
||||
- 使用:
|
||||
- `news.article.create(slug, title, body, category_id, asset_id)`
|
||||
- `news.article.list(size)`
|
||||
- `news.article.detail(id)`
|
||||
- `news.article.remove(id)`
|
||||
- `news.article.edit(id, expected_version, title, body, category_id, asset_id)`
|
||||
- `news.article.require_schedulable(article_id)`
|
||||
|
||||
## ArticleView
|
||||
|
||||
文章创建、所有权查询与带版本的编辑;所有写入校验分类和媒体引用。
|
||||
|
||||
- 类型:`ArticleView`
|
||||
- 字段:
|
||||
- `id: model.id`:记录标识
|
||||
- `slug: Text`:规范化标识
|
||||
- `title: Text`:文章标题
|
||||
- `body: Text`:正文
|
||||
- `version: Int`:当前编辑版本
|
||||
- `category_id: content.category.model.id?`:可选分类引用
|
||||
- `asset_id: media.asset.model.id?`:可选媒体引用
|
||||
|
||||
```dever
|
||||
type ArticleView {
|
||||
id: model.id
|
||||
slug: Text
|
||||
title: Text
|
||||
body: Text
|
||||
version: Int
|
||||
category_id: content.category.model.id?
|
||||
asset_id: media.asset.model.id?
|
||||
}
|
||||
```
|
||||
|
||||
## ArticlePage
|
||||
|
||||
文章创建、所有权查询与带版本的编辑;所有写入校验分类和媒体引用。
|
||||
|
||||
- 类型:`ArticlePage`
|
||||
- 字段:
|
||||
- `rows: List<ArticleView>`:页面记录
|
||||
- `total: Int`:所有权范围内总数
|
||||
|
||||
```dever
|
||||
type ArticlePage {
|
||||
rows: List<ArticleView>
|
||||
total: Int
|
||||
}
|
||||
```
|
||||
|
||||
## create
|
||||
|
||||
文章创建、所有权查询与带版本的编辑;所有写入校验分类和媒体引用。
|
||||
|
||||
- 函数:`create`
|
||||
- 输入:
|
||||
- `slug: Text`:规范化标识
|
||||
- `title: Text`:文章标题
|
||||
- `body: Text`:正文
|
||||
- `category_id: content.category.model.id?`:可选分类引用
|
||||
- `asset_id: media.asset.model.id?`:可选媒体引用
|
||||
- 输出:
|
||||
- `article: ArticleView`:该声明的业务值
|
||||
|
||||
```dever
|
||||
create(
|
||||
slug: Text,
|
||||
title: Text,
|
||||
body: Text,
|
||||
category_id: content.category.model.id?,
|
||||
asset_id: media.asset.model.id?
|
||||
) (article: ArticleView) {
|
||||
normalized_slug = domain.normalize_slug(slug)
|
||||
normalized_title = domain.normalize_title(title)
|
||||
domain.require_content(normalized_slug, normalized_title, body)
|
||||
content.category.require_active(category_id)
|
||||
media.asset.require_ready(asset_id)
|
||||
stored = model.create(
|
||||
{
|
||||
author_id = user.account.current_user()
|
||||
slug = normalized_slug
|
||||
title = normalized_title
|
||||
body = body
|
||||
category_id = category_id
|
||||
asset_id = asset_id
|
||||
}
|
||||
)
|
||||
article = detail(stored.id)
|
||||
}
|
||||
```
|
||||
|
||||
## list
|
||||
|
||||
文章创建、所有权查询与带版本的编辑;所有写入校验分类和媒体引用。
|
||||
|
||||
- 函数:`list`
|
||||
- 输入:
|
||||
- `size: Int`:受限页大小
|
||||
- 输出:
|
||||
- `articles: ArticlePage`:该声明的业务值
|
||||
|
||||
```dever
|
||||
list(size: Int) (articles: ArticlePage) {
|
||||
author = user.account.current_user()
|
||||
articles = ArticlePage {
|
||||
rows = model.owned_articles(author, domain.page_size(size))
|
||||
total = model.count(
|
||||
{
|
||||
where = author_id == author
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## detail
|
||||
|
||||
文章创建、所有权查询与带版本的编辑;所有写入校验分类和媒体引用。
|
||||
|
||||
- 函数:`detail`
|
||||
- 输入:
|
||||
- `id: news.article.model.id`:记录标识
|
||||
- 输出:
|
||||
- `article: ArticleView`:该声明的业务值
|
||||
|
||||
```dever
|
||||
detail(id: model.id) (article: ArticleView) {
|
||||
stored = model.get(id)
|
||||
domain.require_owner(dever.auth.owns_user(stored.author_id))
|
||||
article = ArticleView {
|
||||
id = stored.id
|
||||
slug = stored.slug
|
||||
title = stored.title
|
||||
body = stored.body
|
||||
version = stored.version
|
||||
category_id = stored.category_id
|
||||
asset_id = stored.asset_id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## remove
|
||||
|
||||
文章创建、所有权查询与带版本的编辑;所有写入校验分类和媒体引用。
|
||||
|
||||
- 函数:`remove`
|
||||
- 输入:
|
||||
- `id: news.article.model.id`:记录标识
|
||||
- 输出:
|
||||
- `removed: Int`:该声明的业务值
|
||||
|
||||
```dever
|
||||
remove(id: model.id) (removed: Int) {
|
||||
stored = model.get(id)
|
||||
domain.require_owner(dever.auth.owns_user(stored.author_id))
|
||||
domain.require_draft(stored.status == model.ArticleStatus.Draft)
|
||||
removed = model.delete(
|
||||
{
|
||||
where = id == stored.id and version == stored.version
|
||||
}
|
||||
)
|
||||
domain.require_updated(removed)
|
||||
}
|
||||
```
|
||||
|
||||
## edit
|
||||
|
||||
文章创建、所有权查询与带版本的编辑;所有写入校验分类和媒体引用。
|
||||
|
||||
- 函数:`edit`
|
||||
- 输入:
|
||||
- `id: news.article.model.id`:记录标识
|
||||
- `expected_version: Int`:调用者读取的编辑版本
|
||||
- `title: Text`:文章标题
|
||||
- `body: Text`:正文
|
||||
- `category_id: content.category.model.id?`:可选分类引用
|
||||
- `asset_id: media.asset.model.id?`:可选媒体引用
|
||||
- 输出:
|
||||
- `article: ArticleView`:该声明的业务值
|
||||
|
||||
```dever
|
||||
edit(
|
||||
id: model.id,
|
||||
expected_version: Int,
|
||||
title: Text,
|
||||
body: Text,
|
||||
category_id: content.category.model.id?,
|
||||
asset_id: media.asset.model.id?
|
||||
) (article: ArticleView) {
|
||||
stored = model.get(id)
|
||||
domain.require_owner(dever.auth.owns_user(stored.author_id))
|
||||
domain.require_draft(stored.status == model.ArticleStatus.Draft)
|
||||
normalized_title = domain.normalize_title(title)
|
||||
domain.require_content(stored.slug, normalized_title, body)
|
||||
content.category.require_active(category_id)
|
||||
media.asset.require_ready(asset_id)
|
||||
changed = model.update(
|
||||
{
|
||||
where = id == stored.id and version == expected_version and status == model.ArticleStatus.Draft
|
||||
},
|
||||
{
|
||||
title = normalized_title
|
||||
body = body
|
||||
category_id = category_id
|
||||
asset_id = asset_id
|
||||
version = expected_version + 1
|
||||
}
|
||||
)
|
||||
domain.require_updated(changed)
|
||||
article = detail(stored.id)
|
||||
}
|
||||
```
|
||||
|
||||
## require_schedulable
|
||||
|
||||
文章创建、所有权查询与带版本的编辑;所有写入校验分类和媒体引用。
|
||||
|
||||
- 函数:`require_schedulable`
|
||||
- 输入:
|
||||
- `article_id: news.article.model.id`:文章标识
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_schedulable(article_id: model.id) () {
|
||||
stored = model.get(article_id)
|
||||
domain.require_owner(dever.auth.owns_user(stored.author_id))
|
||||
domain.require_draft(stored.status == model.ArticleStatus.Draft)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,203 @@
|
||||
# news.article.app.publication
|
||||
|
||||
手动发布和定时任务共用原子发布流程,发布快照与审计同步提交。
|
||||
|
||||
- 包:`news.article.app.publication`
|
||||
- 公开类型:无
|
||||
- 公开方法:
|
||||
- `publish`
|
||||
- `published`
|
||||
- `published_detail`
|
||||
- `publish_scheduled`
|
||||
- `scheduled_publication`
|
||||
- `publish_draft`
|
||||
- `existing_publication`
|
||||
- 使用:
|
||||
- `news.article.publish(article_id)`
|
||||
- `news.article.published(size)`
|
||||
- `news.article.published_detail(id)`
|
||||
- `news.article.publish_scheduled(article_id)`
|
||||
- `news.article.scheduled_publication(status, article_id)`
|
||||
- `news.article.publish_draft(article_id)`
|
||||
- `news.article.existing_publication(stored)`
|
||||
|
||||
## publish
|
||||
|
||||
手动发布和定时任务共用原子发布流程,发布快照与审计同步提交。
|
||||
|
||||
- 函数:`publish`
|
||||
- 输入:
|
||||
- `article_id: news.article.model.id`:文章标识
|
||||
- 输出:
|
||||
- `publication: news.article.model.publication.Publication`:该声明的业务值
|
||||
|
||||
```dever
|
||||
transaction publish(article_id: model.id) (publication: model.publication.Publication) {
|
||||
stored = model.get(article_id)
|
||||
domain.require_owner(dever.auth.owns_user(stored.author_id))
|
||||
domain.require_draft(stored.status == model.ArticleStatus.Draft)
|
||||
publication = publish_draft(article_id)
|
||||
}
|
||||
```
|
||||
|
||||
## published
|
||||
|
||||
手动发布和定时任务共用原子发布流程,发布快照与审计同步提交。
|
||||
|
||||
- 函数:`published`
|
||||
- 输入:
|
||||
- `size: Int`:受限页大小
|
||||
- 输出:
|
||||
- `articles: news.article.model.publication.Page`:该声明的业务值
|
||||
|
||||
```dever
|
||||
published(size: Int) (articles: model.publication.Page) {
|
||||
articles = model.publication.list(
|
||||
{
|
||||
order = [published_at.desc, id.desc]
|
||||
size = domain.page_size(size)
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## published_detail
|
||||
|
||||
手动发布和定时任务共用原子发布流程,发布快照与审计同步提交。
|
||||
|
||||
- 函数:`published_detail`
|
||||
- 输入:
|
||||
- `id: news.article.model.publication.id`:记录标识
|
||||
- 输出:
|
||||
- `article: news.article.model.publication.Publication`:该声明的业务值
|
||||
|
||||
```dever
|
||||
published_detail(id: model.publication.id) (article: model.publication.Publication) {
|
||||
article = model.publication.get(id)
|
||||
domain.require_published(text.trim(article.body) != "")
|
||||
}
|
||||
```
|
||||
|
||||
## publish_scheduled
|
||||
|
||||
手动发布和定时任务共用原子发布流程,发布快照与审计同步提交。
|
||||
|
||||
- 函数:`publish_scheduled`
|
||||
- 输入:
|
||||
- `article_id: news.article.model.id`:文章标识
|
||||
- 输出:
|
||||
- `publication: news.article.model.publication.Publication`:该声明的业务值
|
||||
|
||||
```dever
|
||||
transaction publish_scheduled(article_id: model.id) (
|
||||
publication: model.publication.Publication
|
||||
) {
|
||||
stored = model.get(article_id)
|
||||
publication = scheduled_publication(stored.status, article_id)
|
||||
}
|
||||
```
|
||||
|
||||
## scheduled_publication
|
||||
|
||||
手动发布和定时任务共用原子发布流程,发布快照与审计同步提交。
|
||||
|
||||
- 函数:`scheduled_publication`
|
||||
- 输入:
|
||||
- `status: news.article.model.ArticleStatus`:业务状态
|
||||
- `article_id: news.article.model.id`:文章标识
|
||||
- 输出:
|
||||
- `publication: news.article.model.publication.Publication`:该声明的业务值
|
||||
|
||||
```dever
|
||||
scheduled_publication(status: model.ArticleStatus.Published, article_id: model.id) (
|
||||
publication: model.publication.Publication
|
||||
) {
|
||||
stored = model.get(article_id)
|
||||
publication = existing_publication(
|
||||
model.publication.first(
|
||||
{
|
||||
where = slug == stored.slug
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
scheduled_publication(status: model.ArticleStatus.Draft, article_id: model.id) (
|
||||
publication: model.publication.Publication
|
||||
) {
|
||||
publication = publish_draft(article_id)
|
||||
}
|
||||
```
|
||||
|
||||
## publish_draft
|
||||
|
||||
手动发布和定时任务共用原子发布流程,发布快照与审计同步提交。
|
||||
|
||||
- 函数:`publish_draft`
|
||||
- 输入:
|
||||
- `article_id: news.article.model.id`:文章标识
|
||||
- 输出:
|
||||
- `publication: news.article.model.publication.Publication`:该声明的业务值
|
||||
|
||||
```dever
|
||||
publish_draft(article_id: model.id) (publication: model.publication.Publication) {
|
||||
stored = model.get(article_id)
|
||||
domain.require_draft(stored.status == model.ArticleStatus.Draft)
|
||||
content.category.require_active(stored.category_id)
|
||||
media.asset.require_ready(stored.asset_id)
|
||||
published_at = dever.time.now()
|
||||
changed = model.update(
|
||||
{
|
||||
where = id == stored.id and version == stored.version and status == model.ArticleStatus.Draft
|
||||
},
|
||||
{
|
||||
status = model.ArticleStatus.Published
|
||||
published_at = published_at
|
||||
version = stored.version + 1
|
||||
}
|
||||
)
|
||||
domain.require_updated(changed)
|
||||
audit.operation.record(
|
||||
audit.operation.model.OperationKind.Publish,
|
||||
"article",
|
||||
stored.slug
|
||||
)
|
||||
publication = model.publication.upsert(
|
||||
{
|
||||
slug = stored.slug
|
||||
},
|
||||
{
|
||||
title = stored.title
|
||||
body = stored.body
|
||||
published_at = published_at
|
||||
},
|
||||
{
|
||||
title = stored.title
|
||||
body = stored.body
|
||||
published_at = published_at
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## existing_publication
|
||||
|
||||
手动发布和定时任务共用原子发布流程,发布快照与审计同步提交。
|
||||
|
||||
- 函数:`existing_publication`
|
||||
- 输入:
|
||||
- `stored: news.article.model.publication.Publication?`:该声明的业务值
|
||||
- 输出:
|
||||
- `publication: news.article.model.publication.Publication`:该声明的业务值
|
||||
|
||||
```dever
|
||||
existing_publication(stored: model.publication.Publication) (
|
||||
publication: model.publication.Publication
|
||||
) {
|
||||
publication = stored
|
||||
}
|
||||
|
||||
existing_publication(stored: null) (publication: model.publication.Publication) {
|
||||
fail(dever.api.Error.NotFound)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,162 @@
|
||||
# 文章规则
|
||||
|
||||
文章字段规范化、分页边界和发布状态规则集中在纯 Domain。
|
||||
|
||||
- 包:`news.article.domain`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 规范文章标识
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`normalize_slug`
|
||||
- 输入:
|
||||
- `value: Text`:该声明的业务值
|
||||
- 输出:
|
||||
- `slug: Text`:规范化标识
|
||||
|
||||
```dever
|
||||
normalize_slug(value: Text) (slug: Text) pure {
|
||||
slug = text.lower(text.trim(value))
|
||||
}
|
||||
```
|
||||
|
||||
## 规范标题
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`normalize_title`
|
||||
- 输入:
|
||||
- `value: Text`:该声明的业务值
|
||||
- 输出:
|
||||
- `title: Text`:文章标题
|
||||
|
||||
```dever
|
||||
normalize_title(value: Text) (title: Text) pure {
|
||||
title = text.trim(value)
|
||||
}
|
||||
```
|
||||
|
||||
## 限制分页大小
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`page_size`
|
||||
- 输入:
|
||||
- `value: Int`:该声明的业务值
|
||||
- 输出:
|
||||
- `size: Int`:受限页大小
|
||||
|
||||
```dever
|
||||
page_size(value: Int >= 1 and <= 100) (size: Int) pure {
|
||||
size = value
|
||||
}
|
||||
|
||||
page_size(value: other) (size: Int) pure {
|
||||
fail(dever.api.Error.Invalid)
|
||||
}
|
||||
```
|
||||
|
||||
## 检查草稿状态
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`require_draft`
|
||||
- 输入:
|
||||
- `draft: Bool`:该声明的业务值
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_draft(draft: true) () pure {}
|
||||
|
||||
require_draft(draft: false) () pure {
|
||||
fail(dever.api.Error.Conflict)
|
||||
}
|
||||
```
|
||||
|
||||
## 检查文章所有权
|
||||
|
||||
发布操作只允许文章作者执行。
|
||||
|
||||
- 函数:`require_owner`
|
||||
- 输入:
|
||||
- `owned: Bool`:该声明的业务值
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_owner(owned: true) () pure {}
|
||||
|
||||
require_owner(owned: false) () pure {
|
||||
fail(dever.api.Error.Forbidden)
|
||||
}
|
||||
```
|
||||
|
||||
## require_published
|
||||
|
||||
文章字段规范化、分页边界和发布状态规则集中在纯 Domain。
|
||||
|
||||
- 函数:`require_published`
|
||||
- 输入:
|
||||
- `published: Bool`:该声明的业务值
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_published(published: true) () pure {}
|
||||
|
||||
require_published(published: false) () pure {
|
||||
fail(dever.api.Error.NotFound)
|
||||
}
|
||||
```
|
||||
|
||||
## require_updated
|
||||
|
||||
文章字段规范化、分页边界和发布状态规则集中在纯 Domain。
|
||||
|
||||
- 函数:`require_updated`
|
||||
- 输入:
|
||||
- `changed: Int`:条件更新影响行数
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_updated(changed: 1) () pure {}
|
||||
|
||||
require_updated(changed: other) () pure {
|
||||
fail(dever.api.Error.Conflict)
|
||||
}
|
||||
```
|
||||
|
||||
## require_content
|
||||
|
||||
文章字段规范化、分页边界和发布状态规则集中在纯 Domain。
|
||||
|
||||
- 函数:`require_content`
|
||||
- 输入:
|
||||
- `slug: Text`:规范化标识
|
||||
- `title: Text`:文章标题
|
||||
- `body: Text`:正文
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_content(slug: Text, title: Text, body: Text) () pure {
|
||||
require_valid(slug != "" and title != "" and text.trim(body) != "")
|
||||
}
|
||||
```
|
||||
|
||||
## require_valid
|
||||
|
||||
文章字段规范化、分页边界和发布状态规则集中在纯 Domain。
|
||||
|
||||
- 函数:`require_valid`
|
||||
- 输入:
|
||||
- `valid: Bool`:该声明的业务值
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_valid(valid: true) () pure {}
|
||||
|
||||
require_valid(valid: false) () pure {
|
||||
fail(dever.api.Error.Invalid)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,86 @@
|
||||
# 文章模型
|
||||
|
||||
草稿位于租户数据库,REST 由可信用户 owner 限定到作者范围。
|
||||
|
||||
- 包:`news.article.model`
|
||||
- 公开类型:
|
||||
- `ArticleStatus`
|
||||
- `Article`
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 文章状态
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`ArticleStatus`
|
||||
- 分支:
|
||||
- `Draft`:该声明的业务值
|
||||
- `Published`:该声明的业务值
|
||||
|
||||
```dever
|
||||
type ArticleStatus {
|
||||
Draft = "草稿"
|
||||
Published = "已发布"
|
||||
}
|
||||
```
|
||||
|
||||
## 文章记录
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`Article`
|
||||
- 字段:
|
||||
- `private author_id: user.account.model.principal.id`:可信作者标识
|
||||
- `slug: Text(1, 180)`:规范化标识
|
||||
- `title: Text(1, 160)`:文章标题
|
||||
- `body: Text`:正文
|
||||
- `category_id: content.category.model.id?`:可选分类引用
|
||||
- `asset_id: media.asset.model.id?`:可选媒体引用
|
||||
- `private version: Int`:当前编辑版本
|
||||
- `private status: ArticleStatus`:业务状态
|
||||
- `private published_at: DateTime?`:实际发布时间
|
||||
|
||||
```dever
|
||||
type Article {
|
||||
private owner author_id: user.account.model.principal.id = dever.auth.user_id()
|
||||
slug: Text(1, 180) unique
|
||||
title: Text(1, 160)
|
||||
body: Text
|
||||
category_id: content.category.model.id?
|
||||
asset_id: media.asset.model.id?
|
||||
private version: Int default 1
|
||||
private status: ArticleStatus default ArticleStatus.Draft index
|
||||
private published_at: DateTime?
|
||||
}
|
||||
```
|
||||
|
||||
## 作者索引
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 声明:`index(author_id, created_at)`
|
||||
|
||||
```dever
|
||||
index(author_id, created_at)
|
||||
```
|
||||
|
||||
## sql owned_articles
|
||||
|
||||
草稿位于租户数据库,REST 由可信用户 owner 限定到作者范围。
|
||||
|
||||
- 声明:`sql owned_articles`
|
||||
- 输入:
|
||||
- `author: user.account.model.principal.id`:该声明的业务值
|
||||
- `size: Int`:受限页大小
|
||||
- 输出:
|
||||
- `articles: List<app.ArticleView>`:该声明的业务值
|
||||
|
||||
```dever
|
||||
sql owned_articles(author: user.account.model.principal.id, size: Int) (
|
||||
articles: List<app.ArticleView>
|
||||
) {
|
||||
sqlite = "SELECT id, slug, title, body, version, category_id, asset_id FROM article WHERE author_id = ?1 ORDER BY id DESC LIMIT ?2"
|
||||
postgres = "SELECT id, slug, title, body, version, category_id, asset_id FROM article WHERE author_id = $1 ORDER BY id DESC LIMIT $2"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
# 文章公开读模型
|
||||
|
||||
发布动作把可公开内容写入无私密字段的租户内读模型。
|
||||
|
||||
- 包:`news.article.model.publication`
|
||||
- 公开类型:
|
||||
- `Publication`
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 发布记录
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`Publication`
|
||||
- 字段:
|
||||
- `slug: Text(1, 180)`:文章标识
|
||||
- `title: Text(1, 160)`:标题
|
||||
- `body: Text`:正文
|
||||
- `published_at: DateTime`:发布时间
|
||||
|
||||
```dever
|
||||
type Publication {
|
||||
slug: Text(1, 180) unique
|
||||
title: Text(1, 160)
|
||||
body: Text
|
||||
published_at: DateTime index
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,106 @@
|
||||
# 租户应用
|
||||
|
||||
提供平台初始化和登录期租户选择。
|
||||
|
||||
- 包:`platform.tenant.app`
|
||||
- 公开类型:
|
||||
- `TenantView`
|
||||
- 公开方法:
|
||||
- `provision`
|
||||
- `active`
|
||||
- `active_view`
|
||||
- 使用:
|
||||
- `platform.tenant.provision(key, name)`
|
||||
- `platform.tenant.active(tenant_key)`
|
||||
- `platform.tenant.active_view(stored)`
|
||||
|
||||
## 租户视图
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`TenantView`
|
||||
- 字段:
|
||||
- `id: model.id`:租户 ID
|
||||
- `key: Text`:租户键
|
||||
- `name: Text`:名称
|
||||
|
||||
```dever
|
||||
type TenantView {
|
||||
id: model.id
|
||||
key: Text
|
||||
name: Text
|
||||
}
|
||||
```
|
||||
|
||||
## 创建租户
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`provision`
|
||||
- 输入:
|
||||
- `key: Text`:租户键
|
||||
- `name: Text`:名称
|
||||
- 输出:
|
||||
- `tenant: TenantView`:租户
|
||||
|
||||
```dever
|
||||
provision(key: Text, name: Text) (tenant: TenantView) {
|
||||
stored = model.create(
|
||||
{
|
||||
key = domain.normalize_key(key)
|
||||
name = domain.normalize_name(name)
|
||||
}
|
||||
)
|
||||
tenant = TenantView {
|
||||
id = stored.id
|
||||
key = stored.key
|
||||
name = stored.name
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 查找启用租户
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`active`
|
||||
- 输入:
|
||||
- `tenant_key: Text`:租户键
|
||||
- 输出:
|
||||
- `tenant: TenantView`:租户
|
||||
|
||||
```dever
|
||||
active(tenant_key: Text) (tenant: TenantView) {
|
||||
stored = model.first(
|
||||
{
|
||||
where = key == domain.normalize_key(tenant_key)
|
||||
}
|
||||
)
|
||||
tenant = active_view(stored)
|
||||
}
|
||||
```
|
||||
|
||||
## 映射启用租户
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`active_view`
|
||||
- 输入:
|
||||
- `stored: platform.tenant.model.Tenant?`:查询结果
|
||||
- 输出:
|
||||
- `tenant: TenantView`:租户
|
||||
|
||||
```dever
|
||||
active_view(stored: model.Tenant) (tenant: TenantView) {
|
||||
domain.require_active(stored.status == model.TenantStatus.Active)
|
||||
tenant = TenantView {
|
||||
id = stored.id
|
||||
key = stored.key
|
||||
name = stored.name
|
||||
}
|
||||
}
|
||||
|
||||
active_view(stored: null) (tenant: TenantView) {
|
||||
fail(dever.api.Error.Unauthorized)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
# 租户规则
|
||||
|
||||
租户键、名称和状态规则只在本领域使用。
|
||||
|
||||
- 包:`platform.tenant.domain`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 规范租户键
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`normalize_key`
|
||||
- 输入:
|
||||
- `value: Text`:原值
|
||||
- 输出:
|
||||
- `key: Text`:规范值
|
||||
|
||||
```dever
|
||||
normalize_key(value: Text) (key: Text) pure {
|
||||
key = text.lower(text.trim(value))
|
||||
}
|
||||
```
|
||||
|
||||
## 规范租户名
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`normalize_name`
|
||||
- 输入:
|
||||
- `value: Text`:原值
|
||||
- 输出:
|
||||
- `name: Text`:规范值
|
||||
|
||||
```dever
|
||||
normalize_name(value: Text) (name: Text) pure {
|
||||
name = text.trim(value)
|
||||
}
|
||||
```
|
||||
|
||||
## 检查启用状态
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`require_active`
|
||||
- 输入:
|
||||
- `active: Bool`:是否启用
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_active(active: true) () pure {}
|
||||
|
||||
require_active(active: false) () pure {
|
||||
fail(dever.api.Error.Unauthorized)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
# 租户模型
|
||||
|
||||
控制库保存租户目录,租户业务数据不放在本表。
|
||||
|
||||
- 包:`platform.tenant.model`
|
||||
- 公开类型:
|
||||
- `TenantStatus`
|
||||
- `Tenant`
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 租户状态
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`TenantStatus`
|
||||
- 分支:
|
||||
- `Active`:启用
|
||||
- `Disabled`:禁用
|
||||
|
||||
```dever
|
||||
type TenantStatus {
|
||||
Active = "启用"
|
||||
Disabled = "禁用"
|
||||
}
|
||||
```
|
||||
|
||||
## 租户记录
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`Tenant`
|
||||
- 字段:
|
||||
- `uuid: Uuid`:稳定标识
|
||||
- `key: Text(1, 64)`:租户键
|
||||
- `name: Text(1, 100)`:租户名称
|
||||
- `status: TenantStatus`:状态
|
||||
|
||||
```dever
|
||||
global type Tenant {
|
||||
uuid: Uuid generated unique
|
||||
key: Text(1, 64) unique
|
||||
name: Text(1, 100)
|
||||
status: TenantStatus default TenantStatus.Active index
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
# 后台发布计划接口
|
||||
|
||||
后台接口创建发布计划。
|
||||
|
||||
- 包:`publishing.schedule.api.admin.manage`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 创建计划
|
||||
|
||||
创建请求委托给计划应用。
|
||||
|
||||
- 声明:`post schedule`
|
||||
|
||||
```dever
|
||||
post schedule = app.create
|
||||
```
|
||||
@@ -0,0 +1,166 @@
|
||||
# 发布计划应用
|
||||
|
||||
发布计划应用在事务中写入计划并提交持久化 Job。
|
||||
|
||||
- 包:`publishing.schedule.app`
|
||||
- 公开类型:
|
||||
- `PublishRequest`
|
||||
- `ScheduleView`
|
||||
- 公开方法:
|
||||
- `create`
|
||||
- `publish`
|
||||
- `complete`
|
||||
- `publish_claimed`
|
||||
- 使用:
|
||||
- `publishing.schedule.create(article_id, publish_at)`
|
||||
- `publishing.schedule.publish(input)`
|
||||
- `publishing.schedule.complete(status, schedule_id)`
|
||||
- `publishing.schedule.publish_claimed(changed, article_id)`
|
||||
|
||||
## 发布请求
|
||||
|
||||
Job 只携带计划 ID。
|
||||
|
||||
- 类型:`PublishRequest`
|
||||
- 字段:
|
||||
- `schedule_id: model.id`:持久计划标识
|
||||
|
||||
```dever
|
||||
type PublishRequest {
|
||||
schedule_id: model.id
|
||||
}
|
||||
```
|
||||
|
||||
## 计划视图
|
||||
|
||||
返回计划的公开字段。
|
||||
|
||||
- 类型:`ScheduleView`
|
||||
- 字段:
|
||||
- `id: model.id`:记录标识
|
||||
- `article_id: news.article.model.id`:文章标识
|
||||
- `publish_at: DateTime`:计划执行时间
|
||||
- `status: Text`:业务状态
|
||||
|
||||
```dever
|
||||
type ScheduleView {
|
||||
id: model.id
|
||||
article_id: news.article.model.id
|
||||
publish_at: DateTime
|
||||
status: Text
|
||||
}
|
||||
```
|
||||
|
||||
## 创建计划
|
||||
|
||||
业务记录、Job 和返回视图在同一事务内完成。
|
||||
|
||||
- 函数:`create`
|
||||
- 输入:
|
||||
- `article_id: news.article.model.id`:文章标识
|
||||
- `publish_at: DateTime`:计划执行时间
|
||||
- 输出:
|
||||
- `schedule: ScheduleView`:该声明的业务值
|
||||
|
||||
```dever
|
||||
transaction create(article_id: news.article.model.id, publish_at: DateTime) (
|
||||
schedule: ScheduleView
|
||||
) {
|
||||
news.article.require_schedulable(article_id)
|
||||
stored = model.create(
|
||||
{
|
||||
article_id = article_id
|
||||
publish_at = publish_at
|
||||
}
|
||||
)
|
||||
queued = dever.job.enqueue_at(
|
||||
job.publish,
|
||||
PublishRequest {
|
||||
schedule_id = stored.id
|
||||
},
|
||||
uuid.to_text(stored.uuid),
|
||||
publish_at
|
||||
)
|
||||
discarded = queued
|
||||
schedule = ScheduleView {
|
||||
id = stored.id
|
||||
article_id = stored.article_id
|
||||
publish_at = stored.publish_at
|
||||
status = "pending"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 执行计划
|
||||
|
||||
Worker 首先检查计划状态,再调用文章领域的定时发布入口。
|
||||
|
||||
- 函数:`publish`
|
||||
- 输入:
|
||||
- `input: PublishRequest`:经过类型检查的输入
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
transaction publish(input: PublishRequest) () {
|
||||
scheduled = model.get(input.schedule_id)
|
||||
complete(scheduled.status, scheduled.id)
|
||||
}
|
||||
```
|
||||
|
||||
## complete
|
||||
|
||||
发布计划应用在事务中写入计划并提交持久化 Job。
|
||||
|
||||
- 函数:`complete`
|
||||
- 输入:
|
||||
- `status: publishing.schedule.model.ScheduleStatus`:业务状态
|
||||
- `schedule_id: publishing.schedule.model.id`:持久计划标识
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
complete(status: model.ScheduleStatus.Completed, schedule_id: model.id) () {}
|
||||
|
||||
complete(status: model.ScheduleStatus.Cancelled, schedule_id: model.id) () {}
|
||||
|
||||
complete(status: model.ScheduleStatus.Pending, schedule_id: model.id) () {
|
||||
scheduled = model.get(schedule_id)
|
||||
domain.require_due(
|
||||
model.exists(
|
||||
{
|
||||
where = id == schedule_id and publish_at <= dever.time.now()
|
||||
}
|
||||
)
|
||||
)
|
||||
changed = model.update(
|
||||
{
|
||||
where = id == scheduled.id and status == model.ScheduleStatus.Pending
|
||||
},
|
||||
{
|
||||
status = model.ScheduleStatus.Completed
|
||||
}
|
||||
)
|
||||
publish_claimed(changed, scheduled.article_id)
|
||||
}
|
||||
```
|
||||
|
||||
## publish_claimed
|
||||
|
||||
发布计划应用在事务中写入计划并提交持久化 Job。
|
||||
|
||||
- 函数:`publish_claimed`
|
||||
- 输入:
|
||||
- `changed: Int`:条件更新影响行数
|
||||
- `article_id: news.article.model.id`:文章标识
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
publish_claimed(changed: 0, article_id: news.article.model.id) () {}
|
||||
|
||||
publish_claimed(changed: 1, article_id: news.article.model.id) () {
|
||||
publication = news.article.publish_scheduled(article_id)
|
||||
}
|
||||
|
||||
publish_claimed(changed: other, article_id: news.article.model.id) () {
|
||||
fail(dever.api.Error.Conflict)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
# 发布计划规则
|
||||
|
||||
计划任务只允许待发布记录进入执行流程。
|
||||
|
||||
- 包:`publishing.schedule.domain`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## require_due
|
||||
|
||||
计划任务只允许待发布记录进入执行流程。
|
||||
|
||||
- 函数:`require_due`
|
||||
- 输入:
|
||||
- `valid: Bool`:该声明的业务值
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_due(valid: true) () pure {}
|
||||
|
||||
require_due(valid: false) () pure {
|
||||
fail(dever.api.Error.Invalid)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
# 发布计划任务
|
||||
|
||||
发布计划任务由持久化 Job 执行。
|
||||
|
||||
- 包:`publishing.schedule.job`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 队列连接
|
||||
|
||||
使用默认数据库连接。
|
||||
|
||||
- 声明:`database default`
|
||||
|
||||
```dever
|
||||
database default
|
||||
```
|
||||
|
||||
## 发布任务
|
||||
|
||||
任务委托给发布计划应用。
|
||||
|
||||
- 函数:`publish`
|
||||
- 输入:
|
||||
- `input: publishing.schedule.PublishRequest`:发布请求
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
job publish(input: app.PublishRequest) () retry(3) timeout(30000) {
|
||||
app.publish(input)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
# 发布计划模型
|
||||
|
||||
发布计划保存文章的计划时间和幂等状态。
|
||||
|
||||
- 包:`publishing.schedule.model`
|
||||
- 公开类型:
|
||||
- `ScheduleStatus`
|
||||
- `Schedule`
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 计划状态
|
||||
|
||||
计划状态枚举。
|
||||
|
||||
- 类型:`ScheduleStatus`
|
||||
- 分支:
|
||||
- `Pending`:该声明的业务值
|
||||
- `Completed`:该声明的业务值
|
||||
- `Cancelled`:该声明的业务值
|
||||
|
||||
```dever
|
||||
type ScheduleStatus {
|
||||
Pending = "待发布"
|
||||
Completed = "已完成"
|
||||
Cancelled = "已取消"
|
||||
}
|
||||
```
|
||||
|
||||
## 发布计划
|
||||
|
||||
计划关联文章和执行时间。
|
||||
|
||||
- 类型:`Schedule`
|
||||
- 字段:
|
||||
- `uuid: Uuid`:计划独立去重标识
|
||||
- `article_id: news.article.model.id`:文章标识
|
||||
- `publish_at: DateTime`:计划执行时间
|
||||
- `status: ScheduleStatus`:业务状态
|
||||
|
||||
```dever
|
||||
type Schedule {
|
||||
uuid: Uuid generated unique
|
||||
article_id: news.article.model.id unique
|
||||
publish_at: DateTime index
|
||||
status: ScheduleStatus default ScheduleStatus.Pending index
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
# 系统健康命令
|
||||
|
||||
声明本地健康检查命令。
|
||||
|
||||
- 包:`system.health.api`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## ping
|
||||
|
||||
将命令映射到健康检查 App。
|
||||
|
||||
- 声明:`cmd ping`
|
||||
|
||||
```dever
|
||||
cmd ping = app.ping
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
# 系统健康
|
||||
|
||||
提供无需业务状态的本地运行检查。
|
||||
|
||||
- 包:`system.health.app`
|
||||
- 公开类型:
|
||||
- `HealthView`
|
||||
- 公开方法:
|
||||
- `ping`
|
||||
- 使用:
|
||||
- `system.health.ping()`
|
||||
|
||||
## HealthView
|
||||
|
||||
返回健康检查的就绪状态。
|
||||
|
||||
- 类型:`HealthView`
|
||||
- 字段:
|
||||
- `status: Text`:状态
|
||||
|
||||
```dever
|
||||
type HealthView {
|
||||
status: Text
|
||||
}
|
||||
```
|
||||
|
||||
## 健康检查
|
||||
|
||||
返回固定的就绪状态。
|
||||
|
||||
- 函数:`ping`
|
||||
- 输入:无
|
||||
- 输出:
|
||||
- `result: HealthView`:健康状态
|
||||
|
||||
```dever
|
||||
ping() (result: HealthView) {
|
||||
result = HealthView {
|
||||
status = "ready"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
# 账户命令
|
||||
|
||||
首次部署通过显式命令建立租户、用户主体、凭据和所有者成员关系。
|
||||
|
||||
- 包:`user.account.api`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 初始化平台账户
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 声明:`cmd bootstrap`
|
||||
|
||||
```dever
|
||||
cmd bootstrap = app.bootstrap
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
# 后台会话接口
|
||||
|
||||
后台站点登录公开,登出要求认证。
|
||||
|
||||
- 包:`user.account.api.admin.session`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 登录
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 声明:`post login`
|
||||
|
||||
```dever
|
||||
public post login = app.login
|
||||
```
|
||||
|
||||
## 登出
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 声明:`post logout`
|
||||
|
||||
```dever
|
||||
post logout = app.logout
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
# 前台会话接口
|
||||
|
||||
前台复用账户和会话,但由 front 站点签发独立受众令牌。
|
||||
|
||||
- 包:`user.account.api.front.session`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 登录
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 声明:`post login`
|
||||
|
||||
```dever
|
||||
public post login = app.login
|
||||
```
|
||||
|
||||
## 登出
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 声明:`post logout`
|
||||
|
||||
```dever
|
||||
post logout = app.logout
|
||||
```
|
||||
@@ -0,0 +1,350 @@
|
||||
# 账户应用
|
||||
|
||||
账户应用拥有凭据、登录、会话签发和认证身份组装。
|
||||
|
||||
- 包:`user.account.app`
|
||||
- 公开类型:
|
||||
- `AccountView`
|
||||
- `BootstrapView`
|
||||
- `LoginView`
|
||||
- `LogoutView`
|
||||
- `Identity`
|
||||
- 公开方法:
|
||||
- `bootstrap`
|
||||
- `login`
|
||||
- `establish_login`
|
||||
- `logout`
|
||||
- `verify`
|
||||
- `active`
|
||||
- `current_user`
|
||||
- `require_user`
|
||||
- 使用:
|
||||
- `user.account.bootstrap(tenant_key, tenant_name, email, display_name, password)`
|
||||
- `user.account.login(credential_id, password, tenant)`
|
||||
- `user.account.establish_login(credential_id, tenant, site)`
|
||||
- `user.account.logout()`
|
||||
- `user.account.verify(claims)`
|
||||
- `user.account.active(user_id)`
|
||||
- `user.account.current_user()`
|
||||
- `user.account.require_user(value)`
|
||||
|
||||
## 账户视图
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`AccountView`
|
||||
- 字段:
|
||||
- `id: model.principal.id`:用户主体
|
||||
- `uuid: Uuid`:身份 UUID
|
||||
- `email: Text`:邮箱
|
||||
- `display_name: Text`:显示名
|
||||
|
||||
```dever
|
||||
type AccountView {
|
||||
id: model.principal.id
|
||||
uuid: Uuid
|
||||
email: Text
|
||||
display_name: Text
|
||||
}
|
||||
```
|
||||
|
||||
## 初始化结果
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`BootstrapView`
|
||||
- 字段:
|
||||
- `tenant_id: platform.tenant.model.id`:租户
|
||||
- `user_id: model.principal.id`:用户主体
|
||||
- `credential_id: model.id`:凭据 ID
|
||||
|
||||
```dever
|
||||
type BootstrapView {
|
||||
tenant_id: platform.tenant.model.id
|
||||
user_id: model.principal.id
|
||||
credential_id: model.id
|
||||
}
|
||||
```
|
||||
|
||||
## 登录结果
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`LoginView`
|
||||
- 字段:
|
||||
- `account: AccountView`:账户
|
||||
- `tenant_id: platform.tenant.model.id`:租户
|
||||
- `site: Text`:站点
|
||||
|
||||
```dever
|
||||
type LoginView {
|
||||
account: AccountView
|
||||
tenant_id: platform.tenant.model.id
|
||||
site: Text
|
||||
}
|
||||
```
|
||||
|
||||
## 登出结果
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`LogoutView`
|
||||
- 字段:
|
||||
- `revoked: Bool`:是否撤销
|
||||
|
||||
```dever
|
||||
type LogoutView {
|
||||
revoked: Bool
|
||||
}
|
||||
```
|
||||
|
||||
## 认证身份
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`Identity`
|
||||
- 字段:
|
||||
- `id: Text`:JWT subject
|
||||
- `user_id: model.principal.id?`:用户主体
|
||||
- `tenant_id: platform.tenant.model.id?`:租户
|
||||
|
||||
```dever
|
||||
type Identity {
|
||||
id: Text
|
||||
user_id: model.principal.id?
|
||||
tenant_id: platform.tenant.model.id?
|
||||
}
|
||||
```
|
||||
|
||||
## 初始化平台账户
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`bootstrap`
|
||||
- 输入:
|
||||
- `tenant_key: Text`:租户键
|
||||
- `tenant_name: Text`:租户名称
|
||||
- `email: Text`:邮箱
|
||||
- `display_name: Text`:显示名
|
||||
- `password: Secret`:初始密码
|
||||
- 输出:
|
||||
- `result: BootstrapView`:初始化结果
|
||||
|
||||
```dever
|
||||
bootstrap(
|
||||
tenant_key: Text,
|
||||
tenant_name: Text,
|
||||
email: Text,
|
||||
display_name: Text,
|
||||
password: Secret
|
||||
) (result: BootstrapView) {
|
||||
tenant = platform.tenant.provision(tenant_key, tenant_name)
|
||||
input = domain.prepare(email, display_name)
|
||||
hash = blocking(dever.crypto.password_hash(password))
|
||||
principal = model.principal.create(
|
||||
{
|
||||
display_name = input.name
|
||||
}
|
||||
)
|
||||
stored = model.create(
|
||||
{
|
||||
principal_id = principal.id
|
||||
email = input.address
|
||||
password_hash = hash
|
||||
}
|
||||
)
|
||||
user.membership.grant_owner(principal.id, tenant.id)
|
||||
result = BootstrapView {
|
||||
tenant_id = tenant.id
|
||||
user_id = principal.id
|
||||
credential_id = stored.id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 登录
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`login`
|
||||
- 输入:
|
||||
- `credential_id: user.account.model.id`:凭据 ID
|
||||
- `password: Secret`:密码
|
||||
- `tenant: Text`:租户键
|
||||
- 输出:
|
||||
- `result: LoginView`:登录结果
|
||||
|
||||
```dever
|
||||
login(credential_id: model.id, password: Secret, tenant: Text) (result: LoginView) {
|
||||
domain.require_active(
|
||||
model.exists(
|
||||
{
|
||||
where = id == credential_id
|
||||
}
|
||||
)
|
||||
)
|
||||
stored = model.get(credential_id)
|
||||
principal = model.principal.get(stored.principal_id)
|
||||
selected = platform.tenant.active(tenant)
|
||||
domain.require_active(stored.status == model.AccountStatus.Active)
|
||||
domain.require_active(principal.status == model.principal.PrincipalStatus.Active)
|
||||
domain.require_active(
|
||||
blocking(dever.crypto.password_verify(password, stored.password_hash))
|
||||
)
|
||||
authenticated = establish_login(credential_id, selected.key, dever.site.key())
|
||||
dever.auth.issue_cookie(
|
||||
authenticated.subject,
|
||||
uuid.to_text(authenticated.session_id),
|
||||
authenticated.tenant_key
|
||||
)
|
||||
result = authenticated.result
|
||||
}
|
||||
```
|
||||
|
||||
## 建立登录会话
|
||||
|
||||
密码校验在事务外完成。本函数只负责在短事务内重新检查账户、租户和成员关系,再创建会话。
|
||||
|
||||
- 函数:`establish_login`
|
||||
- 输入:
|
||||
- `credential_id: user.account.model.id`:凭据 ID
|
||||
- `tenant: Text`:租户键
|
||||
- `site: Text`:站点键
|
||||
- 输出:
|
||||
- `result: LoginView`:登录视图
|
||||
- `subject: Text`:认证主体
|
||||
- `session_id: Uuid`:会话 UUID
|
||||
- `tenant_key: Text`:租户键
|
||||
|
||||
```dever
|
||||
transaction establish_login(credential_id: model.id, tenant: Text, site: Text) (
|
||||
result: LoginView,
|
||||
subject: Text,
|
||||
session_id: Uuid,
|
||||
tenant_key: Text
|
||||
) {
|
||||
stored = model.get(credential_id)
|
||||
principal = model.principal.get(stored.principal_id)
|
||||
selected = platform.tenant.active(tenant)
|
||||
domain.require_active(stored.status == model.AccountStatus.Active)
|
||||
domain.require_active(principal.status == model.principal.PrincipalStatus.Active)
|
||||
user.membership.require_active(principal.id, selected.id)
|
||||
session = user.session.open(
|
||||
uuid.to_text(principal.uuid),
|
||||
principal.id,
|
||||
selected.id,
|
||||
selected.key,
|
||||
site
|
||||
)
|
||||
result = LoginView {
|
||||
account = AccountView {
|
||||
id = principal.id
|
||||
uuid = principal.uuid
|
||||
email = stored.email
|
||||
display_name = principal.display_name
|
||||
}
|
||||
tenant_id = selected.id
|
||||
site = site
|
||||
}
|
||||
subject = uuid.to_text(principal.uuid)
|
||||
session_id = session.uuid
|
||||
tenant_key = selected.key
|
||||
}
|
||||
```
|
||||
|
||||
## 登出
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`logout`
|
||||
- 输入:无
|
||||
- 输出:
|
||||
- `result: LogoutView`:登出结果
|
||||
|
||||
```dever
|
||||
logout() (result: LogoutView) {
|
||||
user.session.revoke(uuid.parse(dever.auth.session()))
|
||||
dever.auth.clear_cookie()
|
||||
result = LogoutView {
|
||||
revoked = true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 验证认证声明
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`verify`
|
||||
- 输入:
|
||||
- `claims: dever.auth.Claims`:已验签声明
|
||||
- 输出:
|
||||
- `identity: Identity`:可信身份
|
||||
|
||||
```dever
|
||||
verify(claims: dever.auth.Claims) (identity: Identity) {
|
||||
session = user.session.authenticate(uuid.parse(claims.session), claims.site)
|
||||
domain.require_active(session.subject == claims.subject)
|
||||
domain.require_tenant(session.tenant_key, claims.tenant)
|
||||
selected = platform.tenant.active(session.tenant_key)
|
||||
domain.require_active(selected.id == session.tenant_id)
|
||||
active(session.user_id)
|
||||
user.membership.require_active(session.user_id, session.tenant_id)
|
||||
identity = Identity {
|
||||
id = claims.subject
|
||||
user_id = session.user_id
|
||||
tenant_id = session.tenant_id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 验证用户主体
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`active`
|
||||
- 输入:
|
||||
- `user_id: user.account.model.principal.id`:用户主体
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
active(user_id: model.principal.id) () {
|
||||
principal = model.principal.get(user_id)
|
||||
domain.require_active(principal.status == model.principal.PrincipalStatus.Active)
|
||||
}
|
||||
```
|
||||
|
||||
## 当前用户的类型化标识
|
||||
|
||||
读取入口已经验证的可信用户标识,保留 Model ID 类型;不重新查询全局会话数据库,也不接受请求传入的作者 ID。
|
||||
|
||||
- 函数:`current_user`
|
||||
- 输入:无
|
||||
- 输出:
|
||||
- `id: user.account.model.principal.id`:可信用户主体
|
||||
|
||||
```dever
|
||||
current_user() (id: model.principal.id) {
|
||||
id = require_user(dever.auth.user_id())
|
||||
}
|
||||
```
|
||||
|
||||
## 要求可信用户标识
|
||||
|
||||
有可信用户时保留其名义类型;没有用户身份时明确拒绝。
|
||||
|
||||
- 函数:`require_user`
|
||||
- 输入:
|
||||
- `value: user.account.model.principal.id?`:已验证的可空用户标识
|
||||
- 输出:
|
||||
- `id: user.account.model.principal.id`:可信用户主体
|
||||
|
||||
```dever
|
||||
require_user(value: model.principal.id) (id: model.principal.id) {
|
||||
id = value
|
||||
}
|
||||
|
||||
require_user(value: null) (id: model.principal.id) {
|
||||
fail(dever.api.Error.Unauthorized)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,141 @@
|
||||
# 账户规则
|
||||
|
||||
账户输入和认证断言集中在纯 Domain。
|
||||
|
||||
- 包:`user.account.domain`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 账户输入
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`AccountInput`
|
||||
- 字段:
|
||||
- `address: Text`:邮箱
|
||||
- `name: Text`:显示名
|
||||
|
||||
```dever
|
||||
type AccountInput {
|
||||
address: Text
|
||||
name: Text
|
||||
}
|
||||
```
|
||||
|
||||
## 规范邮箱
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`normalize_email`
|
||||
- 输入:
|
||||
- `email: Text`:邮箱
|
||||
- 输出:
|
||||
- `address: Text`:规范邮箱
|
||||
|
||||
```dever
|
||||
normalize_email(email: Text) (address: Text) pure {
|
||||
address = text.lower(text.trim(email))
|
||||
}
|
||||
```
|
||||
|
||||
## 规范显示名
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`normalize_name`
|
||||
- 输入:
|
||||
- `display_name: Text`:显示名
|
||||
- 输出:
|
||||
- `name: Text`:规范名称
|
||||
|
||||
```dever
|
||||
normalize_name(display_name: Text) (name: Text) pure {
|
||||
name = text.trim(display_name)
|
||||
}
|
||||
```
|
||||
|
||||
## 准备账户输入
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`prepare`
|
||||
- 输入:
|
||||
- `email: Text`:邮箱
|
||||
- `display_name: Text`:显示名
|
||||
- 输出:
|
||||
- `input: AccountInput`:规范输入
|
||||
|
||||
```dever
|
||||
prepare(email: Text, display_name: Text) (input: AccountInput) pure {
|
||||
address = normalize_email(email)
|
||||
name = normalize_name(display_name)
|
||||
require(
|
||||
text.contains(address, "@") and not text.starts_with(address, "@") and not text.ends_with(
|
||||
address,
|
||||
"@"
|
||||
),
|
||||
"email must contain a local part and domain"
|
||||
)
|
||||
require(not text.is_empty(name), "display name cannot be blank")
|
||||
input = AccountInput {
|
||||
address = address
|
||||
name = name
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 检查输入
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`require`
|
||||
- 输入:
|
||||
- `valid: Bool`:是否有效
|
||||
- `message: Text`:内部原因
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require(valid: true, message: Text) () pure {}
|
||||
|
||||
require(valid: false, message: Text) () pure {
|
||||
fail(dever.api.Error.Invalid)
|
||||
}
|
||||
```
|
||||
|
||||
## 检查认证状态
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`require_active`
|
||||
- 输入:
|
||||
- `active: Bool`:是否有效
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_active(active: true) () pure {}
|
||||
|
||||
require_active(active: false) () pure {
|
||||
fail(dever.api.Error.Unauthorized)
|
||||
}
|
||||
```
|
||||
|
||||
## 检查租户声明
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`require_tenant`
|
||||
- 输入:
|
||||
- `expected: Text`:可信租户键
|
||||
- `actual: Text?`:JWT 租户声明
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_tenant(expected: Text, actual: Text) () pure {
|
||||
require_active(expected == actual)
|
||||
}
|
||||
|
||||
require_tenant(expected: Text, actual: null) () pure {
|
||||
fail(dever.api.Error.Unauthorized)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
# 账户凭据模型
|
||||
|
||||
控制库只在该模型保存密码哈希,公开身份由 Principal 承担。
|
||||
|
||||
- 包:`user.account.model`
|
||||
- 公开类型:
|
||||
- `AccountStatus`
|
||||
- `Account`
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 凭据状态
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`AccountStatus`
|
||||
- 分支:
|
||||
- `Active`:启用
|
||||
- `Disabled`:禁用
|
||||
|
||||
```dever
|
||||
type AccountStatus {
|
||||
Active = "启用"
|
||||
Disabled = "禁用"
|
||||
}
|
||||
```
|
||||
|
||||
## 账户凭据
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`Account`
|
||||
- 字段:
|
||||
- `principal_id: user.account.model.principal.id`:用户主体
|
||||
- `email: Text(254)`:登录邮箱
|
||||
- `private password_hash: Text(255)`:私密密码哈希
|
||||
- `status: AccountStatus`:凭据状态
|
||||
|
||||
```dever
|
||||
global type Account {
|
||||
principal_id: user.account.model.principal.id unique
|
||||
email: Text(254) unique
|
||||
private password_hash: Text(255)
|
||||
status: AccountStatus default AccountStatus.Active index
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
# 用户主体模型
|
||||
|
||||
Principal 是会话、成员关系和认证 Identity 共用的公开用户身份。
|
||||
|
||||
- 包:`user.account.model.principal`
|
||||
- 公开类型:
|
||||
- `PrincipalStatus`
|
||||
- `Principal`
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 主体状态
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`PrincipalStatus`
|
||||
- 分支:
|
||||
- `Active`:启用
|
||||
- `Disabled`:禁用
|
||||
|
||||
```dever
|
||||
type PrincipalStatus {
|
||||
Active = "启用"
|
||||
Disabled = "禁用"
|
||||
}
|
||||
```
|
||||
|
||||
## 用户主体
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`Principal`
|
||||
- 字段:
|
||||
- `uuid: Uuid`:身份标识
|
||||
- `display_name: Text(1, 64)`:显示名称
|
||||
- `status: PrincipalStatus`:状态
|
||||
|
||||
```dever
|
||||
global type Principal {
|
||||
uuid: Uuid generated unique
|
||||
display_name: Text(1, 64)
|
||||
status: PrincipalStatus default PrincipalStatus.Active index
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
# 后台授权接口
|
||||
|
||||
后台接口管理当前租户、当前站点的角色授权。
|
||||
|
||||
- 包:`user.authorization.api.admin.manage`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 权限目录
|
||||
|
||||
本节声明当前站点权限目录接口。
|
||||
|
||||
- 声明:`get permissions`
|
||||
|
||||
```dever
|
||||
get permissions = app.permissions
|
||||
```
|
||||
|
||||
## 保存角色
|
||||
|
||||
本节声明保存当前站点角色及权限集合的接口。
|
||||
|
||||
- 声明:`post save`
|
||||
|
||||
```dever
|
||||
post save = app.save
|
||||
```
|
||||
|
||||
## 授予角色
|
||||
|
||||
本节声明向用户授予当前站点角色的接口。
|
||||
|
||||
- 声明:`post grant`
|
||||
|
||||
```dever
|
||||
post grant = app.grant
|
||||
```
|
||||
|
||||
## 撤销角色
|
||||
|
||||
本节声明从用户撤销当前站点角色的接口。
|
||||
|
||||
- 声明:`delete revoke`
|
||||
|
||||
```dever
|
||||
delete revoke = app.revoke
|
||||
```
|
||||
|
||||
## 禁用角色
|
||||
|
||||
本节声明禁用当前站点角色的接口。
|
||||
|
||||
- 声明:`delete disable`
|
||||
|
||||
```dever
|
||||
delete disable = app.disable
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
# 前台授权接口
|
||||
|
||||
前台接口管理当前租户、当前站点的角色授权。
|
||||
|
||||
- 包:`user.authorization.api.front.manage`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 权限目录
|
||||
|
||||
本节声明当前站点权限目录接口。
|
||||
|
||||
- 声明:`get permissions`
|
||||
|
||||
```dever
|
||||
get permissions = app.permissions
|
||||
```
|
||||
|
||||
## 保存角色
|
||||
|
||||
本节声明保存当前站点角色及权限集合的接口。
|
||||
|
||||
- 声明:`post save`
|
||||
|
||||
```dever
|
||||
post save = app.save
|
||||
```
|
||||
|
||||
## 授予角色
|
||||
|
||||
本节声明向用户授予当前站点角色的接口。
|
||||
|
||||
- 声明:`post grant`
|
||||
|
||||
```dever
|
||||
post grant = app.grant
|
||||
```
|
||||
|
||||
## 撤销角色
|
||||
|
||||
本节声明从用户撤销当前站点角色的接口。
|
||||
|
||||
- 声明:`delete revoke`
|
||||
|
||||
```dever
|
||||
delete revoke = app.revoke
|
||||
```
|
||||
|
||||
## 禁用角色
|
||||
|
||||
本节声明禁用当前站点角色的接口。
|
||||
|
||||
- 声明:`delete disable`
|
||||
|
||||
```dever
|
||||
delete disable = app.disable
|
||||
```
|
||||
@@ -0,0 +1,108 @@
|
||||
# 授权管理应用
|
||||
|
||||
授权管理应用只适配 Dever 核心角色存储,不保存业务权限副本。
|
||||
|
||||
- 包:`user.authorization.app`
|
||||
- 公开类型:无
|
||||
- 公开方法:
|
||||
- `permissions`
|
||||
- `save`
|
||||
- `grant`
|
||||
- `revoke`
|
||||
- `disable`
|
||||
- 使用:
|
||||
- `user.authorization.permissions()`
|
||||
- `user.authorization.save(id, name, all_permissions, permission_keys)`
|
||||
- `user.authorization.grant(user_id, role_id)`
|
||||
- `user.authorization.revoke(user_id, role_id)`
|
||||
- `user.authorization.disable(role_id)`
|
||||
|
||||
## 权限目录
|
||||
|
||||
本节定义当前站点权限目录的读取入口。
|
||||
|
||||
- 函数:`permissions`
|
||||
- 输入:无
|
||||
- 输出:
|
||||
- `values: List<dever.auth.Permission>`:当前站点权限目录
|
||||
|
||||
```dever
|
||||
permissions() (values: List<dever.auth.Permission>) {
|
||||
values = dever.auth.permissions()
|
||||
}
|
||||
```
|
||||
|
||||
## 保存角色
|
||||
|
||||
本节定义当前站点角色及其权限集合的保存入口。
|
||||
|
||||
- 函数:`save`
|
||||
- 输入:
|
||||
- `id: Text`:角色 ID
|
||||
- `name: Text`:角色名称
|
||||
- `all_permissions: Bool`:是否拥有当前站点全部权限
|
||||
- `permission_keys: List<Text>`:权限键
|
||||
- 输出:
|
||||
- `ok: Bool`:保存成功
|
||||
|
||||
```dever
|
||||
save(id: Text, name: Text, all_permissions: Bool, permission_keys: List<Text>) (
|
||||
ok: Bool
|
||||
) {
|
||||
dever.auth.save_role(id, name, all_permissions, permission_keys)
|
||||
ok = true
|
||||
}
|
||||
```
|
||||
|
||||
## 授予角色
|
||||
|
||||
本节定义向用户授予当前站点角色的入口。
|
||||
|
||||
- 函数:`grant`
|
||||
- 输入:
|
||||
- `user_id: Int`:用户 ID
|
||||
- `role_id: Text`:角色 ID
|
||||
- 输出:
|
||||
- `ok: Bool`:授予成功
|
||||
|
||||
```dever
|
||||
grant(user_id: Int, role_id: Text) (ok: Bool) {
|
||||
dever.auth.grant_role(user_id, role_id)
|
||||
ok = true
|
||||
}
|
||||
```
|
||||
|
||||
## 撤销角色
|
||||
|
||||
本节定义从用户撤销当前站点角色的入口。
|
||||
|
||||
- 函数:`revoke`
|
||||
- 输入:
|
||||
- `user_id: Int`:用户 ID
|
||||
- `role_id: Text`:角色 ID
|
||||
- 输出:
|
||||
- `ok: Bool`:撤销成功
|
||||
|
||||
```dever
|
||||
revoke(user_id: Int, role_id: Text) (ok: Bool) {
|
||||
dever.auth.revoke_role(user_id, role_id)
|
||||
ok = true
|
||||
}
|
||||
```
|
||||
|
||||
## 禁用角色
|
||||
|
||||
本节定义禁用当前站点角色的入口。
|
||||
|
||||
- 函数:`disable`
|
||||
- 输入:
|
||||
- `role_id: Text`:角色 ID
|
||||
- 输出:
|
||||
- `ok: Bool`:禁用成功
|
||||
|
||||
```dever
|
||||
disable(role_id: Text) (ok: Bool) {
|
||||
dever.auth.disable_role(role_id)
|
||||
ok = true
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
# 成员应用
|
||||
|
||||
成员应用建立租户关系并验证有效成员。
|
||||
|
||||
- 包:`user.membership.app`
|
||||
- 公开类型:无
|
||||
- 公开方法:
|
||||
- `grant_owner`
|
||||
- `require_active`
|
||||
- `active`
|
||||
- 使用:
|
||||
- `user.membership.grant_owner(account_id, selected_tenant_id)`
|
||||
- `user.membership.require_active(account_id, selected_tenant_id)`
|
||||
- `user.membership.active(stored)`
|
||||
|
||||
## 授予所有者
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`grant_owner`
|
||||
- 输入:
|
||||
- `account_id: user.account.model.principal.id`:用户主体
|
||||
- `selected_tenant_id: platform.tenant.model.id`:租户
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
grant_owner(
|
||||
account_id: user.account.model.principal.id,
|
||||
selected_tenant_id: platform.tenant.model.id
|
||||
) () {
|
||||
stored = model.create(
|
||||
{
|
||||
user_id = account_id
|
||||
tenant_id = selected_tenant_id
|
||||
role = model.MembershipRole.Owner
|
||||
}
|
||||
)
|
||||
discarded = stored
|
||||
}
|
||||
```
|
||||
|
||||
## 验证有效成员
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`require_active`
|
||||
- 输入:
|
||||
- `account_id: user.account.model.principal.id`:用户主体
|
||||
- `selected_tenant_id: platform.tenant.model.id`:租户
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
require_active(
|
||||
account_id: user.account.model.principal.id,
|
||||
selected_tenant_id: platform.tenant.model.id
|
||||
) () {
|
||||
stored = model.first(
|
||||
{
|
||||
where = user_id == account_id and tenant_id == selected_tenant_id and status == model.MembershipStatus.Active
|
||||
}
|
||||
)
|
||||
active(stored)
|
||||
}
|
||||
```
|
||||
|
||||
## 检查查询结果
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`active`
|
||||
- 输入:
|
||||
- `stored: user.membership.model.Membership?`:成员查询结果
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
active(stored: model.Membership) () {}
|
||||
|
||||
active(stored: null) () {
|
||||
fail(dever.api.Error.Unauthorized)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
# 租户成员模型
|
||||
|
||||
控制库保存用户主体在租户中的角色。
|
||||
|
||||
- 包:`user.membership.model`
|
||||
- 公开类型:
|
||||
- `MembershipRole`
|
||||
- `MembershipStatus`
|
||||
- `Membership`
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 成员角色
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`MembershipRole`
|
||||
- 分支:
|
||||
- `Owner`:所有者
|
||||
- `Editor`:编辑
|
||||
- `Reader`:读者
|
||||
|
||||
```dever
|
||||
type MembershipRole {
|
||||
Owner = "所有者"
|
||||
Editor = "编辑"
|
||||
Reader = "读者"
|
||||
}
|
||||
```
|
||||
|
||||
## 成员状态
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`MembershipStatus`
|
||||
- 分支:
|
||||
- `Active`:启用
|
||||
- `Disabled`:禁用
|
||||
|
||||
```dever
|
||||
type MembershipStatus {
|
||||
Active = "启用"
|
||||
Disabled = "禁用"
|
||||
}
|
||||
```
|
||||
|
||||
## 成员记录
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`Membership`
|
||||
- 字段:
|
||||
- `user_id: user.account.model.principal.id`:用户主体
|
||||
- `tenant_id: platform.tenant.model.id`:租户
|
||||
- `role: MembershipRole`:角色
|
||||
- `status: MembershipStatus`:状态
|
||||
|
||||
```dever
|
||||
global type Membership {
|
||||
user_id: user.account.model.principal.id
|
||||
tenant_id: platform.tenant.model.id
|
||||
role: MembershipRole index
|
||||
status: MembershipStatus default MembershipStatus.Active index
|
||||
}
|
||||
```
|
||||
|
||||
## 成员唯一约束
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 声明:`unique(user_id, tenant_id)`
|
||||
|
||||
```dever
|
||||
unique(user_id, tenant_id)
|
||||
```
|
||||
@@ -0,0 +1,213 @@
|
||||
# 会话应用
|
||||
|
||||
会话应用创建、验证和撤销站点绑定的数据库会话。
|
||||
|
||||
- 包:`user.session.app`
|
||||
- 公开类型:
|
||||
- `SessionView`
|
||||
- `AuthSession`
|
||||
- 公开方法:
|
||||
- `open`
|
||||
- `authenticate`
|
||||
- `revoke`
|
||||
- `auth_session`
|
||||
- `revoke_found`
|
||||
- `cleanup`
|
||||
- 使用:
|
||||
- `user.session.open(subject, account_id, selected_tenant_id, selected_tenant_key, site_key)`
|
||||
- `user.session.authenticate(session_id, site_key)`
|
||||
- `user.session.revoke(session_id)`
|
||||
- `user.session.auth_session(stored)`
|
||||
- `user.session.revoke_found(stored)`
|
||||
- `user.session.cleanup()`
|
||||
|
||||
## 会话视图
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`SessionView`
|
||||
- 字段:
|
||||
- `uuid: Uuid`:会话 UUID
|
||||
|
||||
```dever
|
||||
type SessionView {
|
||||
uuid: Uuid
|
||||
}
|
||||
```
|
||||
|
||||
## 认证会话
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`AuthSession`
|
||||
- 字段:
|
||||
- `subject: Text`:subject
|
||||
- `user_id: user.account.model.principal.id`:用户主体
|
||||
- `tenant_id: platform.tenant.model.id`:租户
|
||||
- `tenant_key: Text`:租户声明
|
||||
|
||||
```dever
|
||||
type AuthSession {
|
||||
subject: Text
|
||||
user_id: user.account.model.principal.id
|
||||
tenant_id: platform.tenant.model.id
|
||||
tenant_key: Text
|
||||
}
|
||||
```
|
||||
|
||||
## 创建会话
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`open`
|
||||
- 输入:
|
||||
- `subject: Text`:subject
|
||||
- `account_id: user.account.model.principal.id`:用户主体
|
||||
- `selected_tenant_id: platform.tenant.model.id`:租户
|
||||
- `selected_tenant_key: Text`:租户键
|
||||
- `site_key: Text`:站点
|
||||
- 输出:
|
||||
- `session: SessionView`:会话
|
||||
|
||||
```dever
|
||||
open(
|
||||
subject: Text,
|
||||
account_id: user.account.model.principal.id,
|
||||
selected_tenant_id: platform.tenant.model.id,
|
||||
selected_tenant_key: Text,
|
||||
site_key: Text
|
||||
) (session: SessionView) {
|
||||
stored = model.create(
|
||||
{
|
||||
subject = subject
|
||||
user_id = account_id
|
||||
tenant_id = selected_tenant_id
|
||||
tenant_key = selected_tenant_key
|
||||
site = site_key
|
||||
expires_at = dever.time.add(dever.time.now(), dever.time.duration(3600000))
|
||||
}
|
||||
)
|
||||
session = SessionView {
|
||||
uuid = stored.uuid
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 验证会话
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`authenticate`
|
||||
- 输入:
|
||||
- `session_id: Uuid?`:会话 UUID
|
||||
- `site_key: Text`:站点
|
||||
- 输出:
|
||||
- `result: AuthSession`:认证会话
|
||||
|
||||
```dever
|
||||
authenticate(session_id: Uuid, site_key: Text) (result: AuthSession) {
|
||||
stored = model.first(
|
||||
{
|
||||
where = uuid == session_id and site == site_key and revoked == false and expires_at > dever.time.now(
|
||||
)
|
||||
}
|
||||
)
|
||||
result = auth_session(stored)
|
||||
}
|
||||
|
||||
authenticate(session_id: null, site_key: Text) (result: AuthSession) {
|
||||
fail(dever.api.Error.Unauthorized)
|
||||
}
|
||||
```
|
||||
|
||||
## 撤销会话
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`revoke`
|
||||
- 输入:
|
||||
- `session_id: Uuid?`:会话 UUID
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
revoke(session_id: Uuid) () {
|
||||
stored = model.first(
|
||||
{
|
||||
where = uuid == session_id and revoked == false
|
||||
}
|
||||
)
|
||||
revoke_found(stored)
|
||||
}
|
||||
|
||||
revoke(session_id: null) () {
|
||||
fail(dever.api.Error.Unauthorized)
|
||||
}
|
||||
```
|
||||
|
||||
## 映射认证会话
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`auth_session`
|
||||
- 输入:
|
||||
- `stored: user.session.model.Session?`:查询结果
|
||||
- 输出:
|
||||
- `result: AuthSession`:认证会话
|
||||
|
||||
```dever
|
||||
auth_session(stored: model.Session) (result: AuthSession) {
|
||||
result = AuthSession {
|
||||
subject = stored.subject
|
||||
user_id = stored.user_id
|
||||
tenant_id = stored.tenant_id
|
||||
tenant_key = stored.tenant_key
|
||||
}
|
||||
}
|
||||
|
||||
auth_session(stored: null) (result: AuthSession) {
|
||||
fail(dever.api.Error.Unauthorized)
|
||||
}
|
||||
```
|
||||
|
||||
## 更新撤销状态
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`revoke_found`
|
||||
- 输入:
|
||||
- `stored: user.session.model.Session?`:查询结果
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
revoke_found(stored: model.Session) () {
|
||||
changed = model.update(
|
||||
stored.id,
|
||||
{
|
||||
revoked = true
|
||||
}
|
||||
)
|
||||
discarded = changed
|
||||
}
|
||||
|
||||
revoke_found(stored: null) () {
|
||||
fail(dever.api.Error.Unauthorized)
|
||||
}
|
||||
```
|
||||
|
||||
## 清理过期会话
|
||||
|
||||
清理已撤销或已过期的会话记录。
|
||||
|
||||
- 函数:`cleanup`
|
||||
- 输入:无
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
cleanup() () {
|
||||
removed = model.delete(
|
||||
{
|
||||
where = revoked == true or expires_at <= dever.time.now()
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
# 会话清理任务
|
||||
|
||||
周期任务清理撤销或过期会话。
|
||||
|
||||
- 包:`user.session.job`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 队列连接
|
||||
|
||||
使用默认数据库连接。
|
||||
|
||||
- 声明:`database default`
|
||||
|
||||
```dever
|
||||
database default
|
||||
```
|
||||
|
||||
## 清理任务
|
||||
|
||||
任务委托给会话应用。
|
||||
|
||||
- 函数:`cleanup`
|
||||
- 输入:无
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
job cleanup() () retry(3) timeout(30000) {
|
||||
app.cleanup()
|
||||
}
|
||||
```
|
||||
|
||||
## 每小时执行
|
||||
|
||||
按 UTC 每小时清理。
|
||||
|
||||
- 声明:`schedule cleanup`
|
||||
|
||||
```dever
|
||||
schedule cleanup = "0 * * * *"
|
||||
```
|
||||
@@ -0,0 +1,47 @@
|
||||
# 会话模型
|
||||
|
||||
控制库保存站点绑定、租户绑定且可撤销的登录会话。
|
||||
|
||||
- 包:`user.session.model`
|
||||
- 公开类型:
|
||||
- `Session`
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 会话记录
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 类型:`Session`
|
||||
- 字段:
|
||||
- `uuid: Uuid`:会话标识
|
||||
- `subject: Text(1, 128)`:JWT subject
|
||||
- `user_id: user.account.model.principal.id`:用户主体
|
||||
- `tenant_id: platform.tenant.model.id`:租户
|
||||
- `tenant_key: Text(1, 64)`:JWT 租户声明
|
||||
- `site: Text(1, 32)`:站点
|
||||
- `expires_at: DateTime`:过期时间
|
||||
- `revoked: Bool`:撤销状态
|
||||
|
||||
```dever
|
||||
global type Session {
|
||||
uuid: Uuid generated unique
|
||||
subject: Text(1, 128)
|
||||
user_id: user.account.model.principal.id
|
||||
tenant_id: platform.tenant.model.id
|
||||
tenant_key: Text(1, 64)
|
||||
site: Text(1, 32)
|
||||
expires_at: DateTime index
|
||||
revoked: Bool default false index
|
||||
}
|
||||
```
|
||||
|
||||
## 会话查询索引
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 声明:`index(user_id, tenant_id, site)`
|
||||
|
||||
```dever
|
||||
index(user_id, tenant_id, site)
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
# content.category.lifecycle
|
||||
|
||||
验证分类创建、规范化、活动状态读取及停用对独立分类的隔离。停用分类的关联拒绝由 HTTP 验收覆盖。
|
||||
|
||||
- 包:`content.category.lifecycle`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## lifecycle
|
||||
|
||||
通过真实 App 操作分类,并核对独立分类的标识和可用状态。
|
||||
|
||||
- 函数:`lifecycle`
|
||||
- 输入:无
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
lifecycle() () {
|
||||
category = app.create(" Dever News ")
|
||||
assert_eq(category.name, "Dever News")
|
||||
assert_eq(category.slug, "dever news")
|
||||
assert_eq(category.status, "active")
|
||||
app.require_active(category.id)
|
||||
app.require_active(null)
|
||||
assert(app.disable(category.id))
|
||||
independent = app.create("Announcements")
|
||||
assert(independent.id != category.id)
|
||||
assert_eq(independent.slug, "announcements")
|
||||
app.require_active(independent.id)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
# 文章发布测试
|
||||
|
||||
验证文章标识和标题规范化、分页边界,以及合法发布条件。数据库发布、权限拒绝和版本冲突由 HTTP 验收覆盖。
|
||||
|
||||
- 包:`news.article.publish`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 发布
|
||||
|
||||
通过真实 Domain 调用核对规范化结果和合法边界。
|
||||
|
||||
- 函数:`publish`
|
||||
- 输入:无
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
publish() () {
|
||||
assert_eq(domain.normalize_slug(" News-Article "), "news-article")
|
||||
assert_eq(domain.normalize_title(" First article "), "First article")
|
||||
assert_eq(domain.page_size(1), 1)
|
||||
assert_eq(domain.page_size(100), 100)
|
||||
domain.require_owner(true)
|
||||
domain.require_draft(true)
|
||||
domain.require_updated(1)
|
||||
domain.require_content("news", "Title", "Body")
|
||||
domain.require_published(true)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
# 平台初始化测试
|
||||
|
||||
在运行器隔离的数据库中建立租户、用户主体和私密凭据,读取所有者成员关系并核对不同租户的标识。
|
||||
|
||||
- 包:`user.account.bootstrap`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 初始化平台账户
|
||||
|
||||
本节定义对应的源码合同。
|
||||
|
||||
- 函数:`bootstrap`
|
||||
- 输入:无
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
bootstrap() () {
|
||||
created = user.account.bootstrap(
|
||||
"tenant-one",
|
||||
"Tenant One",
|
||||
" owner@example.com ",
|
||||
" Owner ",
|
||||
secret("correct horse battery staple")
|
||||
)
|
||||
user.membership.require_active(created.user_id, created.tenant_id)
|
||||
user.account.active(created.user_id)
|
||||
tenant = platform.tenant.active("tenant-one")
|
||||
assert_eq(tenant.id, created.tenant_id)
|
||||
assert_eq(tenant.key, "tenant-one")
|
||||
unrelated = platform.tenant.provision("tenant-two", "Tenant Two")
|
||||
assert(unrelated.id != created.tenant_id)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
# user.account.validation
|
||||
|
||||
验证账户邮箱和显示名称规范化,包括中文名称。非法请求由 HTTP 验收覆盖。
|
||||
|
||||
- 包:`user.account.validation`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## validation
|
||||
|
||||
核对真实 Domain 返回的规范化字段。
|
||||
|
||||
- 函数:`validation`
|
||||
- 输入:无
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
validation() () {
|
||||
prepared = domain.prepare(" OWNER@EXAMPLE.COM ", " Owner ")
|
||||
assert_eq(prepared.address, "owner@example.com")
|
||||
assert_eq(prepared.name, "Owner")
|
||||
unicode_name = domain.prepare("editor@example.com", " 编辑员 ")
|
||||
assert_eq(unicode_name.address, "editor@example.com")
|
||||
assert_eq(unicode_name.name, "编辑员")
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,56 @@
|
||||
# 会话生命周期测试
|
||||
|
||||
在隔离数据库中核对会话身份字段,执行撤销和虚拟时钟清理,再验证独立新会话。旧会话拒绝由 HTTP 验收覆盖。
|
||||
|
||||
- 包:`user.session.lifecycle`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## lifecycle
|
||||
|
||||
通过真实 Session App 核对创建、读取和清理后的独立使用。
|
||||
|
||||
- 函数:`lifecycle`
|
||||
- 输入:无
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
lifecycle() () {
|
||||
account = user.account.bootstrap(
|
||||
"sessions",
|
||||
"Sessions",
|
||||
"session@example.com",
|
||||
"Session user",
|
||||
secret("correct horse battery staple")
|
||||
)
|
||||
session = app.open(
|
||||
"session-subject",
|
||||
account.user_id,
|
||||
account.tenant_id,
|
||||
"sessions",
|
||||
"admin"
|
||||
)
|
||||
authenticated = app.authenticate(session.uuid, "admin")
|
||||
assert_eq(authenticated.subject, "session-subject")
|
||||
assert_eq(authenticated.user_id, account.user_id)
|
||||
assert_eq(authenticated.tenant_id, account.tenant_id)
|
||||
assert_eq(authenticated.tenant_key, "sessions")
|
||||
app.revoke(session.uuid)
|
||||
dever.test.advance_clock(3600000)
|
||||
app.cleanup()
|
||||
replacement = app.open(
|
||||
"new-subject",
|
||||
account.user_id,
|
||||
account.tenant_id,
|
||||
"sessions",
|
||||
"front"
|
||||
)
|
||||
assert(replacement.uuid != session.uuid)
|
||||
current = app.authenticate(replacement.uuid, "front")
|
||||
assert_eq(current.subject, "new-subject")
|
||||
assert_eq(current.user_id, account.user_id)
|
||||
assert_eq(current.tenant_id, account.tenant_id)
|
||||
assert_eq(current.tenant_key, "sessions")
|
||||
}
|
||||
```
|
||||
Reference in new issue
Block a user