Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,8 @@ return error_response(code=20001, message="Bad request", status_code=400)
1. **路由中只用 `success_response` / `created_response` / `paginated_response`**
2. **错误通过抛 `AppException` 子类,由全局 handler 转 `error_response`**
3. **trace_id 默认自动生成,无需手动传**
4. **SSE 端点(流式聊天)不使用信封,直接发 SSE 事件**
4. **SSE 端点不使用信封**:用 `sse_response(generator, extra_headers=...)` 返回(自带 `Cache-Control: no-cache`、
`X-Accel-Buffering: no` 等 `SSE_HEADERS`);聊天回合的 SSE 输出统一走 `api/transports/run_sse.py`,不要自写一套
5. **License 402** 只来自 `edition_ee/licensing/features.py` 的 `FeatureNotLicensed`(40201)/ `SeatLimitExceeded`(40202),
路由与服务层不要手搓 402
6. 时间字段序列化用 `.isoformat()`(模型存的是带时区的 UTC),不要输出无时区字符串
265 changes: 150 additions & 115 deletions .claude/skills/hugagent-backend-dev/references/architecture.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions .claude/skills/hugagent-backend-dev/references/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
|------|------|----------------|------|
| 40001 | 404 | `ResourceNotFoundError` | 资源未找到 |
| 40002 | 404 | `EndpointNotFoundError` | API 端点不存在 |
| 40201 | 402 | `FeatureNotLicensed`(`edition_ee/licensing/features.py`) | License 未授权该能力(EE 路由能力位守卫) |
| 40202 | 402 | `SeatLimitExceeded`(同上) | 席位不足 / License 无效,无法新增用户 |
| 41001 | 409 | `ResourceAlreadyExistsError` | 资源已存在 |
| 41002 | 409 | `ConcurrentModificationError` | 并发修改冲突 |
| 42001 | 429 | `RateLimitExceededError` | 速率限制 |
Expand Down
145 changes: 85 additions & 60 deletions .claude/skills/hugagent-backend-dev/scripts/scaffold_feature.sh
Original file line number Diff line number Diff line change
@@ -1,83 +1,108 @@
#!/usr/bin/env bash
#
# 后端新功能脚手架生成器
# 后端功能模块脚手架:生成 features/<feature>/ 完整骨架
#
# 用法:
# bash .claude/skills/hugagent-backend-dev/scripts/scaffold_feature.sh <feature_name> <FeatureName> <table_name>
# bash .claude/skills/hugagent-backend-dev/scripts/scaffold_feature.sh <feature> <Feature> <table_name>
#
# 示例:
# bash .claude/skills/hugagent-backend-dev/scripts/scaffold_feature.sh bookmark Bookmark bookmarks
#
# 生成文件:
# - src/backend/api/routes/v1/<feature>s.py (路由)
# - src/backend/core/services/<feature>_service.py (服务)
# - src/backend/tests/test_<feature>.py (测试)
# 生成:
# src/backend/features/<feature>/__init__.py
# src/backend/features/<feature>/README.md 职责、公开入口、依赖方向
# src/backend/features/<feature>/extension.py NAME + setup()
# src/backend/features/<feature>/models.py ORM(UTCDateTime + utc_now + JSONType)
# src/backend/features/<feature>/repository.py
# src/backend/features/<feature>/service.py
# src/backend/features/<feature>/api/__init__.py
# src/backend/features/<feature>/api/<feature>s.py 路由(同步 def + run_in_session 示例)
# src/backend/tests/features/<feature>/__init__.py
# src/backend/tests/features/<feature>/test_<feature>.py
#
# 注意: model 和 repository 需手动合并到对应领域文件中
# - core/db/models/<领域>.py ← 添加 ORM 类(并在包 __init__.py re-export)
# - core/db/repository/<领域>.py ← 添加 Repository 类
# - api/routes/v1/__init__.py ← 注册进 CE_ROUTERS / EE_ROUTERS 注册表
# - 运行: alembic revision --autogenerate -m "add ${table_name} table"
# 只建新文件,已存在的文件跳过;不改任何注册表(手动步骤见脚本末尾输出)。
# 不是所有新功能都该进 features/:先按 SKILL.md「放哪里」判断。

set -euo pipefail

FEATURE="${1:?Usage: $0 <feature_name> <FeatureName> <table_name>}"
FEATURE_CAP="${2:?Usage: $0 <feature_name> <FeatureName> <table_name>}"
TABLE="${3:?Usage: $0 <feature_name> <FeatureName> <table_name>}"
FEATURE="${1:?Usage: $0 <feature> <Feature> <table_name>}"
FEATURE_CAP="${2:?Usage: $0 <feature> <Feature> <table_name>}"
TABLE="${3:?Usage: $0 <feature> <Feature> <table_name>}"
FEATURE_UPPER="$(echo "$FEATURE" | tr '[:lower:]' '[:upper:]')"

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TEMPLATE_DIR="$SCRIPT_DIR/../templates"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../../" && pwd)"
BACKEND="$PROJECT_ROOT/src/backend"
PKG="$BACKEND/features/$FEATURE"
TESTS="$BACKEND/tests/features/$FEATURE"

echo "=== HugAgentOS Backend Scaffold ==="
echo "Feature: $FEATURE / $FEATURE_CAP"
echo "Table: $TABLE"
echo ""

# Route file
ROUTE_FILE="$BACKEND/api/routes/v1/${FEATURE}s.py"
if [ ! -f "$ROUTE_FILE" ]; then
render() { # render <template> <dest>
local src="$1" dest="$2"
if [ -f "$dest" ]; then
echo "⊘ Skipped ${dest#$PROJECT_ROOT/} (exists)"
return
fi
sed -e "s/\${feature}/$FEATURE/g" \
-e "s/\${Feature}/$FEATURE_CAP/g" \
-e "s/\${FEATURE_NAME}/$FEATURE_CAP/g" \
"$TEMPLATE_DIR/route.py" > "$ROUTE_FILE"
echo "✓ Created $ROUTE_FILE"
else
echo "⊘ Skipped $ROUTE_FILE (exists)"
fi
-e "s/\${FEATURE}/$FEATURE_UPPER/g" \
-e "s/\${table_name}/$TABLE/g" \
"$src" > "$dest"
echo "✓ Created ${dest#$PROJECT_ROOT/}"
}

# Service file
SERVICE_FILE="$BACKEND/core/services/${FEATURE}_service.py"
if [ ! -f "$SERVICE_FILE" ]; then
sed -e "s/\${feature}/$FEATURE/g" \
-e "s/\${Feature}/$FEATURE_CAP/g" \
"$TEMPLATE_DIR/service.py" > "$SERVICE_FILE"
echo "✓ Created $SERVICE_FILE"
else
echo "⊘ Skipped $SERVICE_FILE (exists)"
fi

# Test file
TEST_FILE="$BACKEND/tests/test_${FEATURE}.py"
if [ ! -f "$TEST_FILE" ]; then
sed -e "s/\${feature}/$FEATURE/g" \
-e "s/\${Feature}/$FEATURE_CAP/g" \
"$TEMPLATE_DIR/test.py" > "$TEST_FILE"
echo "✓ Created $TEST_FILE"
else
echo "⊘ Skipped $TEST_FILE (exists)"
fi
touch_file() { # touch_file <dest> <content>
if [ -f "$1" ]; then
echo "⊘ Skipped ${1#$PROJECT_ROOT/} (exists)"
else
printf '%s\n' "$2" > "$1"
echo "✓ Created ${1#$PROJECT_ROOT/}"
fi
}

echo "=== HugAgentOS backend feature scaffold ==="
echo "Feature: $FEATURE / $FEATURE_CAP Table: $TABLE"
echo ""
echo "=== 手动步骤 ==="
echo "1. 将 model 模板合并到 core/db/models/<领域>.py(替换 \${Feature}=$FEATURE_CAP, \${table_name}=$TABLE),并在包 __init__.py re-export"
echo "2. 将 repository 模板合并到 core/db/repository/<领域>.py(替换 \${Feature}=$FEATURE_CAP)"
echo "3. 在 api/routes/v1/__init__.py 的注册表中添加:"
echo " CE_ROUTERS += ((\"${FEATURE}s\", \"router\"),) # 或 EE_ROUTERS(带 license 能力位)"
echo "4. 创建迁移:"
echo " alembic revision --autogenerate -m \"add $TABLE table\""
echo "5. 应用迁移:"
echo " alembic upgrade head"
echo ""
echo "Done!"

mkdir -p "$PKG/api" "$TESTS"

touch_file "$PKG/__init__.py" "\"\"\"${FEATURE_CAP} feature module.\"\"\""
touch_file "$PKG/api/__init__.py" ""
touch_file "$TESTS/__init__.py" ""
touch_file "$PKG/README.md" "# ${FEATURE_CAP}

负责:(一句话写清本模块拥有的业务能力与数据)。

公开入口:service、repository、api/${FEATURE}s.py。调用方直接依赖实际模块,不保留旧路径转发。

依赖方向:本模块依赖 core(db / auth / infra / harness registry);core 不导入本模块,只通过 extension.setup()
注册的钩子接入。模型由安装清单加载,不依赖运行开关。

版本差异:如 CE/EE 行为不同,统一放 edition.py,CE 只覆盖该入口。"

render "$TEMPLATE_DIR/extension.py" "$PKG/extension.py"
render "$TEMPLATE_DIR/model.py" "$PKG/models.py"
render "$TEMPLATE_DIR/repository.py" "$PKG/repository.py"
render "$TEMPLATE_DIR/service.py" "$PKG/service.py"
render "$TEMPLATE_DIR/route.py" "$PKG/api/${FEATURE}s.py"
render "$TEMPLATE_DIR/test.py" "$TESTS/test_${FEATURE}.py"

cat <<EOF

=== 手动步骤 ===
1. 删掉模板里用不到的示例(如 route 里的 /sync 异步示例),把 README 第一行写实。
2. 安装清单:src/backend/features/installed.py 的 INSTALLED_FEATURES 追加 "features.$FEATURE";
CE 也要提供 → 同步 ce/overlay/src/backend/features/installed.py;EE 专属 → ce/manifest.yaml 排除
src/backend/features/$FEATURE/** 与 src/backend/tests/features/$FEATURE/**。
3. 路由注册(二选一):
- 常驻:api/routes/v1/__init__.py 的 CE_ROUTERS 追加 ("features.$FEATURE.api.${FEATURE}s", "router")
(EE:edition_ee/routes/registry.py 的 EE_ROUTERS 追加三元组并给能力位)——此时删掉 extension.setup() 里的 register_router;
- 可关闭:保留 extension.setup() 里的 register_router(router)。
4. 迁移:make migrate-new msg="add $TABLE table",人工审阅生成结果(SQLite / PostgreSQL 都要能跑);
EE 专属表加进 edition_ee/db/edition_tables.py::EE_ONLY_TABLES。
5. 验证:PYTHONPATH=src/backend pytest src/backend/tests/features/$FEATURE -q
再跑守卫:pytest src/backend/tests/test_no_blocking_async_routes.py src/backend/tests/test_no_event_loop_in_sync_routes.py \\
src/backend/tests/infra/test_no_session_on_event_loop.py src/backend/tests/test_utc_conventions.py \\
src/backend/tests/test_route_import_order.py src/backend/tests/test_db_import_order.py \\
src/backend/tests/features/test_installed_modules.py -q
EOF
38 changes: 38 additions & 0 deletions .claude/skills/hugagent-backend-dev/templates/extension.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Feature module registration template — features/${feature}/extension.py.

Replace ${Feature}, ${feature} with actual names.

Contract (core/harness/registry/__init__.py::load_extensions, features/__init__.py):
- NAME: str module name used in logs and is_enabled()
- setup() -> bool called once at startup; register hooks/routers/workers and return True,
or register nothing and return False when switched off (env flag etc.)
- Optional hooks the host looks up by name:
register_tools() agent tool contributions (product tool composition)
register_gateway_contributions() desktop gateway upload/receipt channels
register_catalog_options() capability catalog options
register_retrieval_context() retrieval context providers
- models.py is imported for every INSTALLED_FEATURES entry regardless of setup() — schema registration
must not depend on runtime switches

Core never imports a feature's implementation: features call core.harness.registry.* to plug in.
"""

import os

NAME = "${feature}"


def enabled() -> bool:
return os.getenv("${FEATURE}_ENABLED", "true").strip().lower() in ("1", "true", "yes", "on")


def setup() -> bool:
if not enabled():
return False
# Routes that must disappear when the module is off are mounted here instead of CE_ROUTERS.
# Always-on routes go into api/routes/v1/__init__.py::CE_ROUTERS (ordering is explicit there).
from core.harness.registry import register_router
from features.${feature}.api.${feature}s import router

register_router(router) # optional policy_key="content_admin" etc. applies the EE edition gate
return True
78 changes: 31 additions & 47 deletions .claude/skills/hugagent-backend-dev/templates/model.py
Original file line number Diff line number Diff line change
@@ -1,74 +1,58 @@
"""ORM model template.

Replace ${Feature}, ${feature}, ${table_name} with actual names.
Copy relevant parts into core/db/models/<领域>.py (并在包 __init__.py re-export).

Where it goes:
- Feature module → features/${feature}/models.py (loaded via INSTALLED_FEATURES by app and alembic)
- Core domain → core/db/models/<domain>.py and re-export in core/db/models/__init__.py
- EE-only table → edition_ee/db/models/… and add the table name to edition_ee/db/edition_tables.py::EE_ONLY_TABLES

Conventions enforced by tests (tests/test_utc_conventions.py, tests/test_db_import_order.py):
- Timestamps: UTCDateTime(timezone=True) + default=utc_now (never datetime.utcnow / naive datetimes)
- JSON: JSONType (JSON on SQLite, JSONB on PostgreSQL) — models must work on both
- Soft delete via deleted_at; FKs declare ondelete
- Model modules import only core.db / core.infra leaves, never services or routes
"""

from datetime import datetime
from sqlalchemy import (
Column, String, Integer, Boolean, Text, TIMESTAMP,
ForeignKey, CheckConstraint, Index,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from core.db.engine import Base
from core.db.models.column_types import JSONType
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
from sqlalchemy import Boolean, CheckConstraint, Column, ForeignKey, Index, Integer, String, Text


class ${Feature}(Base):
"""${Feature} table."""
"""${Feature} — one sentence on what a row represents and who owns it."""

__tablename__ = "${table_name}"

# --- Primary key ---
id = Column(String(64), primary_key=True)

# --- Foreign key (to users_shadow) ---
user_id = Column(
String(64),
ForeignKey("users_shadow.user_id", ondelete="CASCADE"),
nullable=False,
)
# Optional link that must survive parent deletion → SET NULL
project_id = Column(
String(64),
ForeignKey("projects.project_id", ondelete="SET NULL"),
nullable=True,
)

# --- Business fields ---
name = Column(String(200), nullable=False)
description = Column(Text)
status = Column(String(20), nullable=False, default="active")
count = Column(Integer, default=0)
is_active = Column(Boolean, default=True)

# --- Flexible JSONB ---
extra_data = Column("metadata", JSONB, default={})
position = Column(Integer, nullable=False, default=0)
is_enabled = Column(Boolean, nullable=False, default=True)

# --- Soft delete ---
deleted_at = Column(TIMESTAMP(timezone=True))

# --- Timestamps (required) ---
created_at = Column(TIMESTAMP(timezone=True), default=datetime.utcnow)
updated_at = Column(
TIMESTAMP(timezone=True),
default=datetime.utcnow,
onupdate=datetime.utcnow,
)
# Python attribute extra_data, DB column "metadata" (metadata is reserved on declarative models)
extra_data = Column("metadata", JSONType, default=dict)

# --- Relationships ---
user = relationship("UserShadow", back_populates="${feature}s")
created_at = Column(UTCDateTime(timezone=True), default=utc_now)
updated_at = Column(UTCDateTime(timezone=True), default=utc_now, onupdate=utc_now)
deleted_at = Column(UTCDateTime(timezone=True))

# --- Indexes & Constraints ---
__table_args__ = (
CheckConstraint("count >= 0", name="${table_name}_count_check"),
CheckConstraint("position >= 0", name="${table_name}_position_check"),
Index("idx_${table_name}_user_id", "user_id"),
Index("idx_${table_name}_updated_at", "updated_at"),
Index(
"idx_${table_name}_user_updated",
"user_id", "updated_at",
),
Index(
"idx_${table_name}_deleted",
"deleted_at",
postgresql_where=Column("deleted_at").isnot(None),
),
Index(
"idx_${table_name}_metadata_gin",
"metadata",
postgresql_using="gin",
),
)
Loading
Loading