feat: publish Dever core with project scaffolding and versioned AI skills

This commit is contained in:
Antigravity committed 2026-10-04 13:37:30 +00:00
1 parent 70fa73cdc2
commit ac458ffa7f
960 files changed
+221319 -1163

No files matched your search

+56
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,10 @@
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,14 @@
type OperationKind {
Login = "登录"
Logout = "登出"
Publish = "发布"
Upload = "上传"
Access = "授权"
}
type Operation {
actor_id: user.account.model.principal.id?
kind: OperationKind index
resource: Text(1, 120)
resource_id: Text(1, 120)
}
@@ -0,0 +1,3 @@
post create = app.create
delete disable = app.disable
@@ -0,0 +1,41 @@
type CategoryView {
id: model.id
name: Text
slug: Text
status: Text
}
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: model.id) (ok: Bool) {
changed = model.update(
id,
{
status = model.CategoryStatus.Disabled
}
)
discarded = changed
ok = true
}
require_active(id: model.id) () {
stored = model.get(id)
domain.require_active(stored.status == model.CategoryStatus.Active)
}
require_active(id: null) () {}
@@ -0,0 +1,23 @@
normalize_name(value: Text) (name: Text) pure {
name = text.trim(value)
}
normalize_slug(value: Text) (slug: Text) pure {
slug = text.lower(text.trim(value))
}
require_name(value: Text) () pure {
check_name(value != "")
}
check_name(value: true) () pure {}
check_name(value: false) () pure {
fail(dever.api.Error.Invalid)
}
require_active(active: true) () pure {}
require_active(active: false) () pure {
fail(dever.api.Error.Conflict)
}
@@ -0,0 +1,10 @@
type CategoryStatus {
Active = "启用"
Disabled = "禁用"
}
type Category {
name: Text(1, 80)
slug: Text(1, 100) unique
status: CategoryStatus default CategoryStatus.Active index
}
@@ -0,0 +1 @@
post create = app.create
@@ -0,0 +1,45 @@
type RevisionView {
id: model.id
article_id: news.article.model.id
version: Int
title: Text
body: Text
status: Text
}
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,16 @@
type RevisionStatus {
Draft = "草稿"
Published = "已发布"
}
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)
@@ -0,0 +1,3 @@
port.store(file: Upload) (key: Uuid) {
key = dever.storage.put(file)
}
@@ -0,0 +1 @@
post upload = app.store
@@ -0,0 +1,43 @@
type StoreResult {
id: model.id
storage_key: Uuid
filename: Text
content_type: Text
size: Int
}
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(id: model.id) () {
stored = model.get(id)
ready(stored.status == model.AssetStatus.Ready)
}
require_ready(id: null) () {}
ready(available: true) () {}
ready(available: false) () {
fail(dever.api.Error.Conflict)
}
@@ -0,0 +1,12 @@
type AssetStatus {
Ready = "就绪"
Removed = "已删除"
}
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 @@
store(file: Upload) (key: Uuid) fails dever.storage.PutResult
@@ -0,0 +1,9 @@
get list = app.list
get detail = app.detail
post create = app.create
delete remove = app.remove
post publish = app.publish
@@ -0,0 +1,3 @@
get list = app.published
get detail = app.published_detail
@@ -0,0 +1,114 @@
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?
}
type ArticlePage {
rows: List<ArticleView>
total: Int
}
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(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(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(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(
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(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,95 @@
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(size: Int) (articles: model.publication.Page) {
articles = model.publication.list(
{
order = [published_at.desc, id.desc]
size = domain.page_size(size)
}
)
}
published_detail(id: model.publication.id) (article: model.publication.Publication) {
article = model.publication.get(id)
domain.require_published(text.trim(article.body) != "")
}
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(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(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(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,49 @@
normalize_slug(value: Text) (slug: Text) pure {
slug = text.lower(text.trim(value))
}
normalize_title(value: Text) (title: Text) pure {
title = text.trim(value)
}
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: true) () pure {}
require_draft(draft: false) () pure {
fail(dever.api.Error.Conflict)
}
require_owner(owned: true) () pure {}
require_owner(owned: false) () pure {
fail(dever.api.Error.Forbidden)
}
require_published(published: true) () pure {}
require_published(published: false) () pure {
fail(dever.api.Error.NotFound)
}
require_updated(changed: 1) () pure {}
require_updated(changed: other) () pure {
fail(dever.api.Error.Conflict)
}
require_content(slug: Text, title: Text, body: Text) () pure {
require_valid(slug != "" and title != "" and text.trim(body) != "")
}
require_valid(valid: true) () pure {}
require_valid(valid: false) () pure {
fail(dever.api.Error.Invalid)
}
@@ -0,0 +1,25 @@
type ArticleStatus {
Draft = "草稿"
Published = "已发布"
}
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)
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,6 @@
type Publication {
slug: Text(1, 180) unique
title: Text(1, 160)
body: Text
published_at: DateTime index
}
@@ -0,0 +1,41 @@
type TenantView {
id: model.id
key: Text
name: Text
}
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) {
stored = model.first(
{
where = key == domain.normalize_key(tenant_key)
}
)
tenant = active_view(stored)
}
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,13 @@
normalize_key(value: Text) (key: Text) pure {
key = text.lower(text.trim(value))
}
normalize_name(value: Text) (name: Text) pure {
name = text.trim(value)
}
require_active(active: true) () pure {}
require_active(active: false) () pure {
fail(dever.api.Error.Unauthorized)
}
@@ -0,0 +1,11 @@
type TenantStatus {
Active = "启用"
Disabled = "禁用"
}
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 @@
post schedule = app.create
@@ -0,0 +1,76 @@
type PublishRequest {
schedule_id: model.id
}
type ScheduleView {
id: model.id
article_id: news.article.model.id
publish_at: DateTime
status: Text
}
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"
}
}
transaction publish(input: PublishRequest) () {
scheduled = model.get(input.schedule_id)
complete(scheduled.status, scheduled.id)
}
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(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,5 @@
require_due(valid: true) () pure {}
require_due(valid: false) () pure {
fail(dever.api.Error.Invalid)
}
@@ -0,0 +1,5 @@
database default
job publish(input: app.PublishRequest) () retry(3) timeout(30000) {
app.publish(input)
}
@@ -0,0 +1,12 @@
type ScheduleStatus {
Pending = "待发布"
Completed = "已完成"
Cancelled = "已取消"
}
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 @@
cmd ping = app.ping
@@ -0,0 +1,9 @@
type HealthView {
status: Text
}
ping() (result: HealthView) {
result = HealthView {
status = "ready"
}
}
@@ -0,0 +1 @@
cmd bootstrap = app.bootstrap
@@ -0,0 +1,3 @@
public post login = app.login
post logout = app.logout
@@ -0,0 +1,3 @@
public post login = app.login
post logout = app.logout
@@ -0,0 +1,157 @@
type AccountView {
id: model.principal.id
uuid: Uuid
email: Text
display_name: Text
}
type BootstrapView {
tenant_id: platform.tenant.model.id
user_id: model.principal.id
credential_id: model.id
}
type LoginView {
account: AccountView
tenant_id: platform.tenant.model.id
site: Text
}
type LogoutView {
revoked: Bool
}
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) {
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: 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
}
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) {
user.session.revoke(uuid.parse(dever.auth.session()))
dever.auth.clear_cookie()
result = LogoutView {
revoked = true
}
}
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: model.principal.id) () {
principal = model.principal.get(user_id)
domain.require_active(principal.status == model.principal.PrincipalStatus.Active)
}
current_user() (id: model.principal.id) {
id = require_user(dever.auth.user_id())
}
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,49 @@
type AccountInput {
address: Text
name: Text
}
normalize_email(email: Text) (address: Text) pure {
address = text.lower(text.trim(email))
}
normalize_name(display_name: Text) (name: Text) pure {
name = text.trim(display_name)
}
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: true, message: Text) () pure {}
require(valid: false, message: Text) () pure {
fail(dever.api.Error.Invalid)
}
require_active(active: true) () pure {}
require_active(active: false) () pure {
fail(dever.api.Error.Unauthorized)
}
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,11 @@
type AccountStatus {
Active = "启用"
Disabled = "禁用"
}
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,10 @@
type PrincipalStatus {
Active = "启用"
Disabled = "禁用"
}
global type Principal {
uuid: Uuid generated unique
display_name: Text(1, 64)
status: PrincipalStatus default PrincipalStatus.Active index
}
@@ -0,0 +1,9 @@
get permissions = app.permissions
post save = app.save
post grant = app.grant
delete revoke = app.revoke
delete disable = app.disable
@@ -0,0 +1,9 @@
get permissions = app.permissions
post save = app.save
post grant = app.grant
delete revoke = app.revoke
delete disable = app.disable
@@ -0,0 +1,25 @@
permissions() (values: List<dever.auth.Permission>) {
values = dever.auth.permissions()
}
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, role_id: Text) (ok: Bool) {
dever.auth.grant_role(user_id, role_id)
ok = true
}
revoke(user_id: Int, role_id: Text) (ok: Bool) {
dever.auth.revoke_role(user_id, role_id)
ok = true
}
disable(role_id: Text) (ok: Bool) {
dever.auth.disable_role(role_id)
ok = true
}
@@ -0,0 +1,31 @@
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
) () {
stored = model.first(
{
where = user_id == account_id and tenant_id == selected_tenant_id and status == model.MembershipStatus.Active
}
)
active(stored)
}
active(stored: model.Membership) () {}
active(stored: null) () {
fail(dever.api.Error.Unauthorized)
}
@@ -0,0 +1,19 @@
type MembershipRole {
Owner = "所有者"
Editor = "编辑"
Reader = "读者"
}
type MembershipStatus {
Active = "启用"
Disabled = "禁用"
}
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)
@@ -0,0 +1,94 @@
type SessionView {
uuid: Uuid
}
type AuthSession {
subject: Text
user_id: user.account.model.principal.id
tenant_id: platform.tenant.model.id
tenant_key: Text
}
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, 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) () {
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: 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: model.Session) () {
changed = model.update(
stored.id,
{
revoked = true
}
)
discarded = changed
}
revoke_found(stored: null) () {
fail(dever.api.Error.Unauthorized)
}
cleanup() () {
removed = model.delete(
{
where = revoked == true or expires_at <= dever.time.now()
}
)
}
@@ -0,0 +1,7 @@
database default
job cleanup() () retry(3) timeout(30000) {
app.cleanup()
}
schedule cleanup = "0 * * * *"
@@ -0,0 +1,12 @@
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)
@@ -0,0 +1,13 @@
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,11 @@
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,16 @@
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,8 @@
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,37 @@
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")
}
+56
View File
@@ -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)
```
Loaded 100 of 960 files, more files were not shown because too many files have changed in this diff. Show more