diff --git a/.scratch/association-education/design.md b/.scratch/association-education/design.md new file mode 100644 index 0000000..c3fc47c --- /dev/null +++ b/.scratch/association-education/design.md @@ -0,0 +1,14 @@ +# 设计 + +沿用门户 edu.vue 布局、platforms.vue 卡片边框与主题变量,后台沿用 Element Plus 标签页和输入框。卡片复用后台配置的协会 LOGO,沿用现有图片+名称样式。 + +用户明确确认的原型: + +- 门户 v2:https://www.figma.com/design/orfpx89Thcj7WO3hpqC8Si?node-id=5-316 +- 后台配置:https://www.figma.com/design/orfpx89Thcj7WO3hpqC8Si?node-id=7-20 + +独立站点配置键 association_education,JSON 保存 webUrl、mobileUrl,默认均为空。后台 GET/PUT /api/admin/edu/association,公开 GET /api/site/edu/association。写入要求有效工作人员与 edu:edit;读取后台要求有效工作人员和继续教育菜单。URL 仅接受完整 HTTP(S) 地址,允许本地开发地址,不将工单中的 localhost 固化为生产默认值。不请求目标地址。两个字段整体校验后原子保存。 + +门户 /edu/association:两个卡片始终存在,空地址显示暂未开放,无链接;加载与失败状态单独显示,允许重试;有效地址新窗口打开并设置 noopener noreferrer。 + +测试使用独立 SQLite 数据库验证配置持久化与鉴权。浏览器联调时新增接口使用同一业务路由与隔离 SQLite,既有接口转发到原服务;测试配置不写入共享 MySQL。构建在隔离 worktree 执行。验证结果与限制见 tasks.md。 diff --git a/.scratch/association-education/proposal.md b/.scratch/association-education/proposal.md new file mode 100644 index 0000000..801ebed --- /dev/null +++ b/.scratch/association-education/proposal.md @@ -0,0 +1,5 @@ +# 协会教育入口 + +来源:https://gitea.aichengyan.com/cykj/gsfdc/issues/2 (2026-11-02 读取正文及全部评论,0 条评论,无图片)。 + +在继续教育下新增协会教育,固定提供 Web 端、移动端两个外链卡片;工作人员在现有后台继续教育页面配置地址。影响 portal、admin、server;不修改 edu 或教育平台。 diff --git a/.scratch/association-education/spec.md b/.scratch/association-education/spec.md new file mode 100644 index 0000000..d4a096f --- /dev/null +++ b/.scratch/association-education/spec.md @@ -0,0 +1,8 @@ +# 验收条件 + +- 继续教育保留在线教育、教育公告,增加协会教育;导航选中和面包屑正确。 +- 协会教育固定显示 Web、移动端两个入口,响应式布局;可分别配置/清空地址。 +- 保存后重新读取可获得相同配置,门户跳转使用后台配置值,原在线教育数据不受影响。 +- 未配置不可跳转;加载失败可重试,保存失败保留输入。 +- 非 HTTP(S)、缺少主机、携带账号密码、非法端口和控制字符的地址被拒绝,拒绝时不部分保存。 +- 未登录、会员、停用工作人员、没有 edu:edit 的工作人员不能写入。 diff --git a/.scratch/association-education/tasks.md b/.scratch/association-education/tasks.md new file mode 100644 index 0000000..1588621 --- /dev/null +++ b/.scratch/association-education/tasks.md @@ -0,0 +1,22 @@ +- [x] 工程接入、工单全文及占用检查 +- [x] 原型视觉核对与确认(门户 v2、后台配置均获用户明确批准) +- [x] 后端配置与鉴权 +- [x] 门户入口与后台配置表单 +- [x] 任务范围测试、打包与视觉验证 +- [x] 独立代码审查 +- [ ] 远端 PR、集成验证及工单收尾(待授权及检查缺口处理) + +## 验证记录(2026-11-02) + +- `server`: `python -m pytest tests/test_association_education.py -q`,25 项通过。覆盖工作人员及角色权限、默认空值、保存/清空、非法地址、整体拒绝与原数据隔离。 +- 全量 `DATABASE_URL='' python -m pytest -q`:37 项通过、51 项因未连接共享数据库而跳过;跳过项不计为验收通过。 +- `portal`: `npm run build` 通过。 +- `admin`: `VITE_BASE_URL=/ vite build` 通过;本次三个改动文件 ESLint 通过。 +- 后台完整 `npm run build` 被 32 项原有 TypeScript 错误阻止,与主工作目录基线输出逐项相同。全量 ESLint 存在历史格式问题;基线 129 项,本任务首次检查 126 项,其中新增的控制字符正则规则问题已修复,其他均为既有格式问题。不能据此声称完整检查通过。 +- 实际浏览器验证:保存 Web/移动地址、刷新后回显、非法协议拒绝且原配置保持、门户读取新配置、两个入口在新窗口打开对应地址。桌面及 390×844 手机视口视觉检查通过,无横向页面溢出;原在线教育和教育公告保持。 +- 新增配置联调使用隔离 SQLite,未验证 MySQL 写入;未进行生产部署或教育平台内部业务验收。 +- 独立审查由 `review_issue2` 完成,未发现阻塞代码问题;提出的构建和浏览器验证已补齐,MySQL 验证缺口保留。 +- 工单交付前重读正文及全部评论:仍开放,0 条评论,需求无新增。 +- 联调结束后已恢复原主工作目录的门户 13000、后台 13006、后端 18000;核对三个进程工作目录及 HTTP 响应,释放共享测试环境占用。任务实现保存在 worktree,尚未替换主版本。 + +本地证据位于主工作目录 `.local/agent/tasks/issue-2/`:`portal-build.log`、`admin-vite-build.log`、`admin-build.log`、`admin-baseline-types.log`、`admin-lint.log`、`admin-baseline-lint.log`、`portal-actual.jpg`、`portal-mobile.jpg`、`admin-actual.jpg`。不包含账号密码。 diff --git a/admin/src/api/home.ts b/admin/src/api/home.ts index 60979f9..b16ac4c 100644 --- a/admin/src/api/home.ts +++ b/admin/src/api/home.ts @@ -75,3 +75,16 @@ export function saveRelatedLinks(data: Record) { export function saveCompliance(data: Record) { return request.put({ url: '/api/admin/home/compliance', data }) } + +export interface AssociationEducationLinks { + webUrl: string + mobileUrl: string +} + +export function fetchAssociationEducation() { + return request.get({ url: '/api/admin/edu/association' }) +} + +export function saveAssociationEducation(data: AssociationEducationLinks) { + return request.put({ url: '/api/admin/edu/association', data }) +} diff --git a/admin/src/views/edu/components/AssociationEducation.vue b/admin/src/views/edu/components/AssociationEducation.vue new file mode 100644 index 0000000..b0a74ea --- /dev/null +++ b/admin/src/views/edu/components/AssociationEducation.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/admin/src/views/edu/index.vue b/admin/src/views/edu/index.vue index b5e2d0e..08e9d84 100644 --- a/admin/src/views/edu/index.vue +++ b/admin/src/views/edu/index.vue @@ -3,8 +3,12 @@
-

每张卡片一张图,图下是教育网站名称。门户点击后在新窗口打开网址。

- 保存 +

每张卡片一张图,图下是教育网站名称。门户点击后在新窗口打开网址。

+ 保存
@@ -14,7 +18,12 @@ @pick="(payload) => assignImage(item, payload)" @clear="clearImage(item)" /> - +
@@ -26,7 +35,9 @@ controls-position="right" />
- 删除 + 删除
+ + diff --git a/server/app/api/admin/edu.py b/server/app/api/admin/edu.py index a53bca6..004c068 100644 --- a/server/app/api/admin/edu.py +++ b/server/app/api/admin/edu.py @@ -32,3 +32,53 @@ def write_edu_platforms(body: EduPlatformsWrite, db: Session = Depends(get_db)) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc return {"ok": True} + + +class AssociationEducationWrite(BaseModel): + webUrl: str = Field(max_length=2048) + mobileUrl: str = Field(max_length=2048) + + +def _association_access(db: Session, principal: dict, *, write: bool = False) -> None: + from app.models.rbac import AdminUser + from app.services.rbac import granted_resources + + try: + user_id = int(principal.get("sub", "")) + except (ValueError, TypeError): + raise HTTPException(status_code=401, detail="登录已失效") from None + user = db.get(AdminUser, user_id) + if user is None or user.status != "active": + raise HTTPException(status_code=401, detail="登录已失效") + resources = granted_resources(db, user_id) + allowed = any( + (item.type == "button" and item.permission == "edu:edit") if write + else (item.type == "menu" and item.path == "/edu") + for item in resources + ) + if not allowed: + raise HTTPException(status_code=403, detail="没有继续教育操作权限") + + +@router.get("/edu/association") +def read_association(db: Session = Depends(get_db), principal: dict = Depends(require_staff)) -> dict: + from app.services.association_education import read_links + + _association_access(db, principal) + return read_links(db) + + +@router.put("/edu/association") +def write_association( + body: AssociationEducationWrite, + db: Session = Depends(get_db), + principal: dict = Depends(require_staff), +) -> dict: + from app.services.association_education import save_links + + _association_access(db, principal, write=True) + try: + save_links(db, body.webUrl, body.mobileUrl) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True} diff --git a/server/app/api/site/edu.py b/server/app/api/site/edu.py index 6d9e763..bc9cbb7 100644 --- a/server/app/api/site/edu.py +++ b/server/app/api/site/edu.py @@ -10,3 +10,10 @@ router = APIRouter() @router.get("/edu/platforms") def read_edu_platforms(db: Session = Depends(get_db)) -> dict: return public_edu_platforms(db) + + +@router.get("/edu/association") +def read_association(db: Session = Depends(get_db)) -> dict: + from app.services.association_education import read_links + + return read_links(db) diff --git a/server/app/services/association_education.py b/server/app/services/association_education.py new file mode 100644 index 0000000..9a2978f --- /dev/null +++ b/server/app/services/association_education.py @@ -0,0 +1,49 @@ +"""协会教育只配置两个外链,不承载学习业务。""" +import json +from urllib.parse import urlsplit + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.content import SiteSetting + +SETTING_KEY = "association_education" + + +def validate_url(value: str) -> str: + text = value.strip() + if not text: + return "" + try: + parsed = urlsplit(text) + valid = ( + len(text) <= 2048 + and parsed.scheme.lower() in {"http", "https"} + and bool(parsed.hostname) + and parsed.username is None + and parsed.password is None + and "\\" not in text + and not any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in text) + and (parsed.port is None or 1 <= parsed.port <= 65535) + ) + except ValueError: + valid = False + if not valid: + raise ValueError("请填写完整的 http:// 或 https:// 网址,不包含账号密码") + return text + + +def read_links(db: Session) -> dict[str, str]: + row = db.scalar(select(SiteSetting).where(SiteSetting.key == SETTING_KEY)) + stored = json.loads(row.value) if row else {} + return {key: stored.get(key, "") for key in ("webUrl", "mobileUrl")} + + +def save_links(db: Session, web_url: str, mobile_url: str) -> None: + value = json.dumps({"webUrl": validate_url(web_url), "mobileUrl": validate_url(mobile_url)}) + row = db.scalar(select(SiteSetting).where(SiteSetting.key == SETTING_KEY)) + if row is None: + db.add(SiteSetting(key=SETTING_KEY, value=value)) + else: + row.value = value + db.commit() diff --git a/server/tests/test_association_education.py b/server/tests/test_association_education.py new file mode 100644 index 0000000..ccbc050 --- /dev/null +++ b/server/tests/test_association_education.py @@ -0,0 +1,87 @@ +"""配置持久化和鉴权使用独立 SQLite 数据库,不连接业务库。""" +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool +from app.api.admin.edu import router as admin_router +from app.api.site.edu import router as site_router +from app.core.config import get_settings +from app.core.security import create_access_token +from app.db import Base, get_db +from app.models.content import SiteSetting +from app.models.rbac import AdminUser, AdminRole, AdminResource, AdminUserRole, AdminRoleResource +from app.services.association_education import validate_url + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setenv('JWT_SECRET', 'test-secret-must-be-32-characters!') + get_settings.cache_clear() + engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) + Base.metadata.create_all(engine, tables=[m.__table__ for m in (SiteSetting, AdminUser, AdminRole, AdminResource, AdminUserRole, AdminRoleResource)]) + with Session(engine) as db: + db.add_all([AdminUser(id=i, username=f'user{i}', password_hash='unused', display_name='测试', status='disabled' if i == 3 else 'active') for i in (1,2,3,4)]) + db.add_all([AdminRole(id=1, code='editor', name='教育编辑'), AdminRole(id=2, code='reader', name='只读')]) + db.add_all([AdminResource(id=1, type='menu', name='edu', title='继续教育', path='/edu'), AdminResource(id=2, type='button', name='edu:edit', title='编辑', permission='edu:edit')]) + db.flush() + db.add_all([AdminUserRole(user_id=i, role_id=1) for i in (1,3)]) + db.add_all([AdminRoleResource(role_id=1, resource_id=i) for i in (1,2)]) + db.add(AdminUserRole(user_id=4, role_id=2)) + db.add(AdminRoleResource(role_id=2, resource_id=1)) + db.add(SiteSetting(key='unrelated',value='preserve')) + db.commit() + def database(): + with Session(engine) as db: + yield db + app=FastAPI() + app.include_router(admin_router,prefix='/api/admin') + app.include_router(site_router,prefix='/api/site') + app.dependency_overrides[get_db]=database + with TestClient(app) as api: + yield api + with Session(engine) as db: + assert db.query(SiteSetting).filter_by(key='unrelated').one().value == 'preserve' + engine.dispose() + get_settings.cache_clear() + +def headers(user=1,kind='staff'): + return {'Authorization':f'Bearer {create_access_token(str(user),kind)}'} + +def test_round_trip_atomic_rejection_and_clear(client): + public='/api/site/edu/association' + admin='/api/admin/edu/association' + assert client.get(public).json()=={'webUrl':'','mobileUrl':''} + data={'webUrl':'http://127.0.0.1:9800','mobileUrl':'http://127.0.0.1:9801'} + assert client.put(admin,headers=headers(),json=data).status_code==200 + assert client.get(public).json()==data + assert client.get(admin,headers=headers()).json()==data + assert client.put(admin,headers=headers(),json={'webUrl':'https://example.com/new','mobileUrl':'javascript:alert(1)'}).status_code==400 + assert client.get(public).json()==data + assert client.put(admin,headers=headers(),json={'webUrl':'','mobileUrl':'https://example.com/m'}).status_code==200 + assert client.get(public).json()=={'webUrl':'','mobileUrl':'https://example.com/m'} + +@pytest.mark.parametrize('auth',[None,(1,'member'),(2,'staff'),(3,'staff'),(99,'staff')]) +def test_authorization(client,auth): + kw={'headers':headers(*auth)} if auth else {} + assert client.get('/api/admin/edu/association',**kw).status_code in (401,403) + assert client.put('/api/admin/edu/association',json={'webUrl':'https://example.com','mobileUrl':''},**kw).status_code in (401,403) + assert client.get('/api/site/edu/association').json()=={'webUrl':'','mobileUrl':''} + +@pytest.mark.parametrize('url',['javascript:alert(1)','//example.com','https://','https://a:99999','https://a:bad','https://user:password@example.com','https://a\\@b','https://a\nb','data:text/html,test','https://exa mple.com']) +def test_invalid_url(url): + with pytest.raises(ValueError):validate_url(url) + +@pytest.mark.parametrize('url',['','https://example.com/path?q=中文#section','http://127.0.0.1:9800','https://[::1]:9801']) +def test_valid_url(url): + assert validate_url(url)==url + + +def test_read_only_staff_cannot_write(client): + assert client.get('/api/admin/edu/association',headers=headers(4)).status_code == 200 + assert client.put('/api/admin/edu/association',headers=headers(4),json={'webUrl':'https://example.com','mobileUrl':''}).status_code == 403 + +@pytest.mark.parametrize('data',[{}, {'webUrl':''}, {'webUrl':'x'*2049,'mobileUrl':''}, {'webUrl':None,'mobileUrl':''}]) +def test_invalid_payload_does_not_change_settings(client,data): + assert client.put('/api/admin/edu/association',headers=headers(),json=data).status_code == 422 + assert client.get('/api/site/edu/association').json() == {'webUrl':'','mobileUrl':''}