Skip to content
This repository was archived by the owner on Aug 2, 2026. It is now read-only.

Commit fe99e3d

Browse files
committed
feat(demo): 重构 DEMO 模式,预置演示账号与业务数据,每次启动自动重置
安全加固:is_prod 排除 demo;ENV=production+demo 拒绝启动;验证码签名仅 debug 跳过;WinRM 统一走 settings.demo。演示账号:新增 demo-seed 预置 superadmin/siteadmin/user;登录页快速登录区。演示业务数据:新增 2c2a demo CLI;按 site_group_id 清理所有业务数据含用户手动创建的;每次启动自动重置。WinRM 假成功可见化:demo_mode 字段+启动横幅。
1 parent 15d2cf9 commit fe99e3d

12 files changed

Lines changed: 1037 additions & 21 deletions

File tree

‎app/api/v1/hosts.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,11 @@ async def test_host_connection(
226226
client = await AsyncWinRMClient.from_host_config(host)
227227
try:
228228
res = await client.execute_command("whoami")
229-
return {"success": res.success, "output": res.std_out.strip(), "error": res.std_err}
229+
return {
230+
"success": res.success,
231+
"output": res.std_out.strip(),
232+
"error": res.std_err,
233+
"demo_mode": res.demo_mode,
234+
}
230235
finally:
231236
await client.close()

‎app/auth/routes.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,14 @@ async def send_email_code(
271271
error=send_result.error,
272272
)
273273
if settings.debug or settings.demo:
274+
# demo/debug 模式:返回验证码以便演示流程继续,同时日志 warning 输出
275+
log.warning(
276+
"demo_email_code_revealed",
277+
email=body.email,
278+
code=result.code,
279+
ttl=result.ttl,
280+
mode="demo" if settings.demo else "debug",
281+
)
274282
return SendEmailCodeResponse(
275283
sent=False, expires_in=result.ttl, dev_code=result.code
276284
)

‎app/captcha/router.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,9 @@ async def verify(request: Request):
137137
ts_header = request.headers.get("X-Captcha-Ts", "")
138138
sign_header = request.headers.get("X-Captcha-Sign", "")
139139

140-
# 开发 / 演示模式:签名缺失时跳过校验(便于 curl 调试)
141-
skip_sign = (settings.debug or settings.demo) and not ts_header and not sign_header
140+
# 开发模式:签名缺失时跳过校验(便于 curl 调试)
141+
# 注意:demo 模式不跳过签名校验,演示应展示完整安全链路
142+
skip_sign = settings.debug and not ts_header and not sign_header
142143

143144
if skip_sign:
144145
sign_ok, sign_err = True, ""

‎app/cli/account.py‎

Lines changed: 150 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
用法:
44
2c2a account createsuperuser # 创建超级管理员
55
2c2a account create # 创建普通用户
6+
2c2a account demo-seed # 预置演示账号(超管/站点管理员/普通用户)
67
2c2a account list # 列出用户
78
2c2a account changepassword <username> # 修改密码
89
2c2a account activate <username> # 启用账号
@@ -15,10 +16,11 @@
1516
"""
1617
from __future__ import annotations
1718

19+
import hashlib
1820
from datetime import datetime, timezone
1921

2022
import typer
21-
from sqlalchemy import select
23+
from sqlalchemy import insert, select
2224

2325
from app.cli.utils import (
2426
blake2b_prehash_interactive,
@@ -30,17 +32,163 @@
3032
success,
3133
warn,
3234
)
33-
from app.models.user import User, UserBan, UserProfile
35+
from app.models.tenant import SiteGroup
36+
from app.models.user import User, UserBan, UserProfile, user_site_group_admins, user_site_groups
3437
from app.security.password import hash_password
3538

3639
account_app = typer.Typer(help="账户管理", no_args_is_help=True)
3740

41+
# ── 演示账号配置 ──
42+
# 统一密码:demo123456(满足 ≥8 位要求)
43+
DEMO_PASSWORD = "demo123456"
44+
DEMO_ACCOUNTS = [
45+
{
46+
"username": "superadmin",
47+
"email": "demo-superadmin@2c2a.local",
48+
"is_superuser": True,
49+
"is_staff": True,
50+
"is_verified": True,
51+
"label": "超级管理员",
52+
"bind_tenant_admin": False,
53+
},
54+
{
55+
"username": "siteadmin",
56+
"email": "demo-siteadmin@2c2a.local",
57+
"is_superuser": False,
58+
"is_staff": True,
59+
"is_verified": True,
60+
"label": "站点管理员",
61+
"bind_tenant_admin": True,
62+
},
63+
{
64+
"username": "user",
65+
"email": "demo-user@2c2a.local",
66+
"is_superuser": False,
67+
"is_staff": False,
68+
"is_verified": True,
69+
"label": "普通用户",
70+
"bind_tenant_admin": False,
71+
},
72+
]
73+
3874

3975
async def _get_user_by_username(session, username: str) -> User | None:
4076
result = await session.execute(select(User).where(User.username == username))
4177
return result.scalar_one_or_none()
4278

4379

80+
async def seed_demo_accounts() -> dict:
81+
"""预置演示账号与默认站点组。
82+
83+
幂等:已存在的账号跳过,未存在的创建。
84+
供 CLI(``2c2a account demo-seed``)与 lifespan 启动钩子复用。
85+
86+
:returns: {"created": [用户名...], "skipped": [用户名...], "site_group": 站点名}
87+
"""
88+
prehash = hashlib.blake2b(DEMO_PASSWORD.encode(), digest_size=64).hexdigest()
89+
password_hash = hash_password(prehash)
90+
created: list[str] = []
91+
skipped: list[str] = []
92+
93+
async with db_session() as session:
94+
# 1. 确保默认站点组存在(供 siteadmin 绑定)
95+
site_group = (
96+
await session.execute(select(SiteGroup).where(SiteGroup.slug == "demo"))
97+
).scalar_one_or_none()
98+
if site_group is None:
99+
site_group = SiteGroup(
100+
name="演示站点",
101+
slug="demo",
102+
site_name="2c2a 演示站点",
103+
is_active=True,
104+
)
105+
session.add(site_group)
106+
await session.flush()
107+
108+
# 2. 创建/更新演示账号
109+
for spec in DEMO_ACCOUNTS:
110+
existing = await _get_user_by_username(session, spec["username"])
111+
if existing is not None:
112+
# 已存在则跳过(不覆盖密码/权限,避免破坏用户后续修改)
113+
skipped.append(spec["username"])
114+
continue
115+
user = User(
116+
username=spec["username"],
117+
email=spec["email"],
118+
password_hash=password_hash,
119+
is_active=True,
120+
is_staff=spec["is_staff"],
121+
is_superuser=spec["is_superuser"],
122+
is_verified=spec["is_verified"],
123+
)
124+
session.add(user)
125+
await session.flush()
126+
session.add(UserProfile(user_id=user.id))
127+
128+
# 直接操作关联表,避免 ORM 关系懒加载触发 MissingGreenlet
129+
await session.execute(
130+
insert(user_site_groups).values(
131+
user_id=user.id, site_group_id=site_group.id
132+
)
133+
)
134+
if spec["bind_tenant_admin"]:
135+
await session.execute(
136+
insert(user_site_group_admins).values(
137+
user_id=user.id, site_group_id=site_group.id
138+
)
139+
)
140+
141+
created.append(spec["username"])
142+
143+
return {"created": created, "skipped": skipped, "site_group": "demo"}
144+
145+
146+
@account_app.command("demo-seed")
147+
def demo_seed(
148+
force: bool = typer.Option(False, "--force", "-f", help="覆盖已存在的演示账号密码与权限"),
149+
):
150+
"""预置演示账号:superadmin / siteadmin / user(密码统一 demo123456)。
151+
152+
仅用于 demo/开发环境,生产环境禁止使用。
153+
"""
154+
from app.core.config import settings
155+
156+
# 安全护栏:生产环境拒绝执行
157+
if settings.is_prod:
158+
error("生产环境禁止预置演示账号")
159+
raise typer.Exit(1)
160+
161+
if force:
162+
# --force 模式:先删除已存在的演示账号再重建
163+
async def _wipe():
164+
async with db_session() as session:
165+
for spec in DEMO_ACCOUNTS:
166+
existing = await _get_user_by_username(session, spec["username"])
167+
if existing is not None:
168+
await session.delete(existing)
169+
170+
run_async(_wipe())
171+
172+
result = run_async(seed_demo_accounts())
173+
174+
from app.cli.utils import console
175+
176+
console.print()
177+
console.print("[bold cyan]演示账号已预置[/bold cyan]")
178+
console.print(f" 站点组: [green]演示站点 (slug=demo)[/green]")
179+
console.print()
180+
console.print(f" {'用户名':<16} {'密码':<14} {'身份':<10} {'状态'}")
181+
console.print(f" {'-'*16} {'-'*14} {'-'*10} {'-'*8}")
182+
for spec in DEMO_ACCOUNTS:
183+
status = "新建" if spec["username"] in result["created"] else "已存在跳过"
184+
console.print(
185+
f" [bold]{spec['username']:<16}[/bold] "
186+
f"{DEMO_PASSWORD:<14} {spec['label']:<10} {status}"
187+
)
188+
console.print()
189+
warn("演示密码为 demo123456,仅用于 demo/开发环境,禁止用于生产")
190+
191+
44192
@account_app.command("createsuperuser")
45193
def create_superuser(
46194
username: str = typer.Option(..., "--username", "-u", help="用户名"),
@@ -92,8 +240,6 @@ def _create_user(
92240
if len(password) < 8:
93241
error("密码至少 8 位")
94242
raise typer.Exit(1)
95-
import hashlib
96-
97243
prehash = hashlib.blake2b(password.encode(), digest_size=64).hexdigest()
98244
else:
99245
prehash = blake2b_prehash_interactive("密码")
@@ -176,8 +322,6 @@ def change_password(
176322
if len(password) < 8:
177323
error("密码至少 8 位")
178324
raise typer.Exit(1)
179-
import hashlib
180-
181325
prehash = hashlib.blake2b(password.encode(), digest_size=64).hexdigest()
182326
else:
183327
prehash = blake2b_prehash_interactive("新密码")

‎app/cli/demo.py‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""演示数据管理命令。
2+
3+
用法:
4+
2c2a demo seed # 仅预置演示业务数据(不清理,重复执行会叠加)
5+
2c2a demo clean # 清理所有演示业务数据
6+
2c2a demo reset # 清理 + 重建演示业务数据(推荐)
7+
2c2a demo accounts # 预置演示账号(superadmin/siteadmin/user)
8+
"""
9+
from __future__ import annotations
10+
11+
import typer
12+
13+
from app.cli.utils import console, error, run_async, success, warn
14+
15+
demo_app = typer.Typer(help="演示数据管理", no_args_is_help=True)
16+
17+
18+
@demo_app.command("accounts")
19+
def accounts():
20+
"""预置演示账号:superadmin / siteadmin / user(密码 demo123456)。"""
21+
from app.core.config import settings
22+
23+
if settings.is_prod:
24+
error("生产环境禁止预置演示账号")
25+
raise typer.Exit(1)
26+
27+
from app.cli.account import seed_demo_accounts
28+
29+
result = run_async(seed_demo_accounts())
30+
created = result["created"]
31+
skipped = result["skipped"]
32+
33+
console.print()
34+
console.print("[bold cyan]演示账号[/bold cyan]")
35+
console.print(f" 新建: {', '.join(created) if created else '无'}")
36+
console.print(f" 跳过: {', '.join(skipped) if skipped else '无'}")
37+
console.print()
38+
warn("密码统一 demo123456,仅用于 demo/开发环境")
39+
40+
41+
@demo_app.command("seed")
42+
def seed():
43+
"""预置演示业务数据(不清理已有数据,重复执行会叠加)。"""
44+
from app.core.config import settings
45+
46+
if settings.is_prod:
47+
error("生产环境禁止预置演示数据")
48+
raise typer.Exit(1)
49+
50+
from app.cli.demo_data import seed_demo_business_data
51+
52+
result = run_async(seed_demo_business_data())
53+
created = result["created"]
54+
55+
console.print()
56+
success("演示业务数据已预置")
57+
for table, count in created.items():
58+
console.print(f" · {table:<20} 新建 {count} 条")
59+
60+
61+
@demo_app.command("clean")
62+
def clean():
63+
"""清理所有演示业务数据(按 [DEMO] 前缀精准删除)。"""
64+
from app.core.config import settings
65+
66+
if settings.is_prod:
67+
error("生产环境禁止操作演示数据")
68+
raise typer.Exit(1)
69+
70+
from app.cli.demo_data import clean_demo_business_data
71+
72+
result = run_async(clean_demo_business_data())
73+
deleted = result["deleted"]
74+
75+
console.print()
76+
success("演示业务数据已清理")
77+
total = 0
78+
for table, count in deleted.items():
79+
if count > 0:
80+
console.print(f" · {table:<20} 删除 {count} 条")
81+
total += count
82+
console.print(f" 合计删除 {total} 条")
83+
84+
85+
@demo_app.command("reset")
86+
def reset():
87+
"""清理 + 重建演示业务数据(推荐,保证环境干净)。"""
88+
from app.core.config import settings
89+
90+
if settings.is_prod:
91+
error("生产环境禁止操作演示数据")
92+
raise typer.Exit(1)
93+
94+
from app.cli.demo_data import reset_demo_business_data
95+
96+
result = run_async(reset_demo_business_data())
97+
cleaned = result["cleaned"]["deleted"]
98+
created = result["seeded"]["created"]
99+
100+
console.print()
101+
success("演示业务数据已重置")
102+
console.print()
103+
console.print("[bold]清理:[/bold]")
104+
for table, count in cleaned.items():
105+
if count > 0:
106+
console.print(f" · {table:<20} 删除 {count} 条")
107+
console.print()
108+
console.print("[bold]新建:[/bold]")
109+
for table, count in created.items():
110+
console.print(f" · {table:<20} 新建 {count} 条")

0 commit comments

Comments
 (0)