diff --git a/.env.debug b/.env.debug index 637f41e3..30e7614a 100644 --- a/.env.debug +++ b/.env.debug @@ -1,3 +1,4 @@ VITE_APM_STORE_LOCAL_MODE=true VITE_APM_STORE_BASE_URL=/local_amd64-store VITE_APM_STORE_STATS_BASE_URL=/local_stats +VITE_SPARK_BACKEND_BASE_URL=http://127.0.0.1:8000 diff --git a/.env.production b/.env.production index 67893ab1..2d0a583d 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,3 @@ VITE_APM_STORE_BASE_URL=https://erotica.spark-app.store VITE_APM_STORE_STATS_BASE_URL=https://feedback.spark-app.store +VITE_SPARK_BACKEND_BASE_URL=https://account.spark-app.store diff --git a/.gitignore b/.gitignore index 7cec79c6..f4a7205f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,13 @@ dist-electron release *.local +# Local secrets and databases +.env +.env.*.local +*.sqlite +*.sqlite3 +*.db + # Test coverage coverage .nyc_output @@ -40,3 +47,4 @@ yarn.lock test-results.json .worktrees/ +.superpowers/ diff --git a/SIDEBAR_CONFIG.md b/SIDEBAR_CONFIG.md new file mode 100644 index 00000000..330936a5 --- /dev/null +++ b/SIDEBAR_CONFIG.md @@ -0,0 +1,84 @@ +# 侧边栏入口配置 (Sidebar Config) + +星火应用商店支持通过服务器上的 JSON 文件动态配置左侧侧边栏的入口项。 + +## 配置文件位置 + +将 `sidebar-config.json` 放置在服务器应用仓库的架构目录下: + +``` +# Spark 仓库 +{baseUrl}/{arch}-store/sidebar-config.json + +# APM 仓库 +{baseUrl}/{arch}-apm/sidebar-config.json +``` + +例如: +- `https://example.com/amd64-store/sidebar-config.json` +- `https://example.com/arm64-store/sidebar-config.json` + +## JSON 格式 + +每个入口项为一个对象,包含以下字段: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | string | ✅ | 唯一标识符,对应分类名或自定义 ID | +| `name` | string | ✅ | 侧边栏显示的入口名称 | +| `icon` | string | ❌ | FontAwesome 图标类名,如 `fas fa-gamepad` | +| `type` | string | ❌ | 入口类型:`category`(分类筛选)、`search`(搜索关键词)、`link`(外部链接)。默认为 `category` | +| `value` | string | ❌ | 与 `type` 配合使用的值。`category` 类型为分类名,`search` 类型为搜索关键词。默认为 `id` 的值 | + +## 示例配置 + +```json +[ + { + "id": "games", + "name": "游戏专区", + "icon": "fas fa-gamepad", + "type": "category", + "value": "games" + }, + { + "id": "devtools", + "name": "开发工具", + "icon": "fas fa-code", + "type": "category", + "value": "development" + }, + { + "id": "office", + "name": "办公学习", + "icon": "fas fa-book", + "type": "category", + "value": "office" + }, + { + "id": "ai-search", + "name": "AI 应用", + "icon": "fas fa-robot", + "type": "search", + "value": "AI" + } +] +``` + +## 入口类型说明 + +### `category` 类型 +点击后在"全部应用"页面按指定分类筛选应用。`value` 字段对应 `categories.json` 中的分类键名。 + +### `search` 类型 +点击后自动使用 `value` 字段的值进行搜索。适用于快速入口,如"AI 应用"、"微信"等热门关键词。 + +### `link` 类型(预留) +用于跳转到外部链接或内部页面。后续版本支持。 + +## 注意事项 + +- 如果两个仓库(Spark 和 APM)都存在 `sidebar-config.json`,相同的 `id` 会自动去重合并 +- 配置文件不存在时,侧边栏不会显示额外的入口项,不影响正常使用 +- 入口项显示在"首页推荐"和"全部应用"之间,以分隔线区分 +- 每个入口项会显示对应分类或搜索下的应用数量 diff --git a/docs/superpowers/plans/2026-05-18-spark-backend-account-collections.md b/docs/superpowers/plans/2026-05-18-spark-backend-account-collections.md new file mode 100644 index 00000000..a2e38c52 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-spark-backend-account-collections.md @@ -0,0 +1,1368 @@ +# Spark Backend Account Collections Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend `spark-unionid-server` with forum-level profile data, favorite folders/items, downloaded-app records, API docs, deployment docs, and verification. + +**Architecture:** Keep the current FastAPI + SQLAlchemy stack. Add focused ORM models and route modules for favorites/downloaded records, keep existing auth and app-list behavior compatible, and expose only authenticated user-owned resources through `/me/*` endpoints. + +**Tech Stack:** Python 3.11+, FastAPI, SQLAlchemy 2.x, Alembic, PyMySQL/MySQL, Pydantic, httpx, pytest. + +--- + +## File Structure + +Backend repository: `/home/spark/Desktop/shenmo-spark-store/spark-store-backend`. + +Modify: +- `app/models/user.py` - add forum level fields and relationships. +- `app/models/__init__.py` - keep unchanged unless the repository later starts importing models from the package root. +- `app/db/base.py` - import new models for metadata. +- `alembic/versions/0001_initial.py` - update initial schema with new columns/tables because the repo has not shipped production migrations yet. +- `app/services/flarum.py` - extract forum groups/level from Flarum actor payload. +- `app/schemas/auth.py` - return forum fields from `/auth/flarum` and `/me`. +- `app/api/routes/auth.py` - store forum fields during auth upsert. +- `app/api/router.py` - include new routes. +- `docs/api.md` - document new endpoints. +- `docs/deployment.md` - mention upgraded migration/deploy flow remains unchanged. + +Create: +- `app/models/favorite.py` - favorite folder and item ORM models. +- `app/models/downloaded_app.py` - downloaded record ORM model. +- `app/schemas/favorite.py` - favorite request/response schemas. +- `app/schemas/downloaded_app.py` - downloaded app schemas. +- `app/api/routes/favorites.py` - favorite folder/item endpoints. +- `app/api/routes/downloaded_apps.py` - downloaded app endpoints. +- `tests/test_favorites.py` - favorite behavior tests. +- `tests/test_downloaded_apps.py` - downloaded record tests. + +## Task 1: Add Account Data Models And Migration + +**Files:** +- Modify: `app/models/user.py` +- Modify: `app/db/base.py` +- Modify: `alembic/versions/0001_initial.py` +- Create: `app/models/favorite.py` +- Create: `app/models/downloaded_app.py` +- Test: `tests/test_account_models.py` + +- [ ] **Step 1: Write failing model tests** + +Create `tests/test_account_models.py` with: + +```python +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.db.base import Base +from app.models.downloaded_app import DownloadedApp +from app.models.favorite import FavoriteFolder, FavoriteItem +from app.models.user import User + + +def test_favorite_folder_cascades_items_and_enforces_user_scope(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + + with Session(engine) as session: + user = User(flarum_user_id="123", username="momen") + folder = FavoriteFolder(user=user, name="默认收藏夹") + folder.items.append( + FavoriteItem( + app_key="app:office:wps", + pkgname="wps", + name="WPS", + category="office", + icon_url="https://example.invalid/wps.png", + ) + ) + session.add(folder) + session.commit() + + assert session.query(FavoriteFolder).one().name == "默认收藏夹" + assert session.query(FavoriteItem).one().app_key == "app:office:wps" + + session.delete(folder) + session.commit() + + assert session.query(FavoriteItem).count() == 0 + + +def test_downloaded_app_and_forum_level_persist(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + + with Session(engine) as session: + user = User( + flarum_user_id="123", + username="momen", + forum_level="管理员", + forum_groups='["管理员"]', + ) + session.add(user) + session.flush() + + session.add( + DownloadedApp( + user_id=user.id, + app_key="app:office:wps", + pkgname="wps", + name="WPS", + category="office", + selected_origin="apm", + version="1.0.0", + package_arch="amd64", + ) + ) + session.commit() + + stored_user = session.query(User).one() + stored_download = session.query(DownloadedApp).one() + assert stored_user.forum_level == "管理员" + assert stored_user.forum_groups == '["管理员"]' + assert stored_download.selected_origin == "apm" +``` + +- [ ] **Step 2: Run model tests and verify failure** + +Run: `.venv/bin/pytest tests/test_account_models.py -v` + +Expected: FAIL with `ModuleNotFoundError` for `app.models.downloaded_app` or `app.models.favorite`. + +- [ ] **Step 3: Add favorite models** + +Create `app/models/favorite.py` with: + +```python +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from app.models.user import User + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class FavoriteFolder(Base): + __tablename__ = "favorite_folders" + __table_args__ = ( + UniqueConstraint("user_id", "name", name="uq_favorite_folders_user_name"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + name: Mapped[str] = mapped_column(String(128), default="", server_default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + user: Mapped["User"] = relationship(back_populates="favorite_folders") + items: Mapped[list["FavoriteItem"]] = relationship( + back_populates="folder", + cascade="all, delete-orphan", + ) + + +class FavoriteItem(Base): + __tablename__ = "favorite_items" + __table_args__ = ( + UniqueConstraint("folder_id", "app_key", name="uq_favorite_items_folder_app"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + folder_id: Mapped[int] = mapped_column(ForeignKey("favorite_folders.id"), index=True) + app_key: Mapped[str] = mapped_column(String(512), index=True) + pkgname: Mapped[str] = mapped_column(String(255), index=True) + name: Mapped[str] = mapped_column(String(255), default="", server_default="") + category: Mapped[str] = mapped_column(String(128), default="", server_default="") + icon_url: Mapped[str] = mapped_column(String(1024), default="", server_default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + folder: Mapped[FavoriteFolder] = relationship(back_populates="items") +``` + +- [ ] **Step 4: Add downloaded app model** + +Create `app/models/downloaded_app.py` with: + +```python +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from app.models.user import User + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class DownloadedApp(Base): + __tablename__ = "downloaded_apps" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + app_key: Mapped[str] = mapped_column(String(512), index=True) + pkgname: Mapped[str] = mapped_column(String(255), index=True) + name: Mapped[str] = mapped_column(String(255), default="", server_default="") + category: Mapped[str] = mapped_column(String(128), default="", server_default="") + selected_origin: Mapped[str] = mapped_column(String(16), default="", server_default="") + version: Mapped[str] = mapped_column(String(255), default="", server_default="") + package_arch: Mapped[str] = mapped_column(String(64), default="", server_default="") + downloaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) + + user: Mapped["User"] = relationship(back_populates="downloaded_apps") +``` + +- [ ] **Step 5: Update user model relationships and forum fields** + +Modify `app/models/user.py` to include: + +```python +from app.models.downloaded_app import DownloadedApp # only under TYPE_CHECKING +from app.models.favorite import FavoriteFolder # only under TYPE_CHECKING +``` + +Inside the `User` class after `avatar_url`: + +```python + forum_level: Mapped[str] = mapped_column(String(128), default="论坛用户", server_default="论坛用户") + forum_groups: Mapped[str] = mapped_column(String(1024), default="[]", server_default="[]") +``` + +Inside the `User` class after `app_lists`: + +```python + favorite_folders: Mapped[list["FavoriteFolder"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + downloaded_apps: Mapped[list["DownloadedApp"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) +``` + +- [ ] **Step 6: Import new models in base metadata** + +Modify `app/db/base.py` to import: + +```python +from app.models.downloaded_app import DownloadedApp # noqa: E402,F401 +from app.models.favorite import FavoriteFolder, FavoriteItem # noqa: E402,F401 +``` + +- [ ] **Step 7: Update initial migration** + +Modify `alembic/versions/0001_initial.py`: + +1. Add `forum_level` and `forum_groups` columns to `users` after `avatar_url`. +2. Create `favorite_folders`, `favorite_items`, and `downloaded_apps` tables after `users` and before dependent reads are needed. +3. Add indexes and unique constraints matching the models. +4. Drop those tables in `downgrade()` before dropping `users`. + +Use these column definitions: + +```python +sa.Column("forum_level", sa.String(length=128), server_default="论坛用户", nullable=False), +sa.Column("forum_groups", sa.String(length=1024), server_default="[]", nullable=False), +``` + +Use these table definitions: + +```python +op.create_table( + "favorite_folders", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=128), server_default="", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=utc_now, nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=utc_now, nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "name", name="uq_favorite_folders_user_name"), +) +op.create_index(op.f("ix_favorite_folders_user_id"), "favorite_folders", ["user_id"], unique=False) + +op.create_table( + "favorite_items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("folder_id", sa.Integer(), nullable=False), + sa.Column("app_key", sa.String(length=512), nullable=False), + sa.Column("pkgname", sa.String(length=255), nullable=False), + sa.Column("name", sa.String(length=255), server_default="", nullable=False), + sa.Column("category", sa.String(length=128), server_default="", nullable=False), + sa.Column("icon_url", sa.String(length=1024), server_default="", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=utc_now, nullable=False), + sa.ForeignKeyConstraint(["folder_id"], ["favorite_folders.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("folder_id", "app_key", name="uq_favorite_items_folder_app"), +) +op.create_index(op.f("ix_favorite_items_app_key"), "favorite_items", ["app_key"], unique=False) +op.create_index(op.f("ix_favorite_items_folder_id"), "favorite_items", ["folder_id"], unique=False) +op.create_index(op.f("ix_favorite_items_pkgname"), "favorite_items", ["pkgname"], unique=False) + +op.create_table( + "downloaded_apps", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("app_key", sa.String(length=512), nullable=False), + sa.Column("pkgname", sa.String(length=255), nullable=False), + sa.Column("name", sa.String(length=255), server_default="", nullable=False), + sa.Column("category", sa.String(length=128), server_default="", nullable=False), + sa.Column("selected_origin", sa.String(length=16), server_default="", nullable=False), + sa.Column("version", sa.String(length=255), server_default="", nullable=False), + sa.Column("package_arch", sa.String(length=64), server_default="", nullable=False), + sa.Column("downloaded_at", sa.DateTime(timezone=True), server_default=utc_now, nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), +) +op.create_index(op.f("ix_downloaded_apps_app_key"), "downloaded_apps", ["app_key"], unique=False) +op.create_index(op.f("ix_downloaded_apps_downloaded_at"), "downloaded_apps", ["downloaded_at"], unique=False) +op.create_index(op.f("ix_downloaded_apps_pkgname"), "downloaded_apps", ["pkgname"], unique=False) +op.create_index(op.f("ix_downloaded_apps_user_id"), "downloaded_apps", ["user_id"], unique=False) +``` + +- [ ] **Step 8: Run model tests and migration verification** + +Run: + +```bash +.venv/bin/pytest tests/test_account_models.py -v +DATABASE_URL="sqlite:////tmp/opencode/spark-backend-account-models.sqlite3" .venv/bin/alembic upgrade head +``` + +Expected: model tests PASS and Alembic exits 0. + +- [ ] **Step 9: Run full backend tests** + +Run: `.venv/bin/pytest -v` + +Expected: all tests PASS. + +- [ ] **Step 10: Commit account models** + +Run: + +```bash +git add app/models app/db/base.py alembic/versions/0001_initial.py tests/test_account_models.py +git commit -m "feat(account): add collection data models" +``` + +Expected: commit succeeds. + +## Task 2: Add Forum Level To Auth Profile + +**Files:** +- Modify: `app/services/flarum.py` +- Modify: `app/schemas/auth.py` +- Modify: `app/api/routes/auth.py` +- Test: `tests/test_auth.py` +- Test: `tests/test_flarum_service.py` + +- [ ] **Step 1: Add failing Flarum group extraction tests** + +Append to `tests/test_flarum_service.py`: + +```python +@pytest.mark.anyio +async def test_fetch_flarum_profile_maps_forum_groups(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api" + assert request.headers["Authorization"] == "Token forum-token" + return httpx.Response( + 200, + json={ + "data": { + "relationships": { + "actor": {"data": {"type": "users", "id": "123"}} + } + }, + "included": [ + { + "type": "users", + "id": "123", + "attributes": { + "username": "momen", + "displayName": "Momen", + "avatarUrl": "https://bbs.spark-app.store/avatar.png", + }, + "relationships": { + "groups": { + "data": [{"type": "groups", "id": "1"}] + } + }, + }, + { + "type": "groups", + "id": "1", + "attributes": {"nameSingular": "管理员"}, + }, + ], + }, + ) + + mock_async_client(monkeypatch, handler) + + profile = await fetch_flarum_profile("forum-token", "123") + + assert profile["forum_level"] == "管理员" + assert profile["forum_groups"] == '["管理员"]' + + +@pytest.mark.anyio +async def test_fetch_flarum_profile_falls_back_to_forum_user_level(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api" + return httpx.Response( + 200, + json={ + "data": { + "relationships": { + "actor": {"data": {"type": "users", "id": "123"}} + } + }, + "included": [ + { + "type": "users", + "id": "123", + "attributes": { + "username": "momen", + "displayName": "Momen", + "avatarUrl": "https://bbs.spark-app.store/avatar.png", + }, + } + ], + }, + ) + + mock_async_client(monkeypatch, handler) + + profile = await fetch_flarum_profile("forum-token", "123") + + assert profile["forum_level"] == "论坛用户" + assert profile["forum_groups"] == "[]" +``` + +- [ ] **Step 2: Add failing auth response test** + +Append to `tests/test_auth.py`: + +```python +def test_auth_flarum_returns_forum_level(client, monkeypatch): + async def fake_fetch_profile(token: str, user_id: str): + return { + "flarum_user_id": user_id, + "username": "momen", + "display_name": "Momen", + "avatar_url": "https://bbs.spark-app.store/avatar.png", + "forum_level": "管理员", + "forum_groups": '["管理员"]', + } + + monkeypatch.setattr("app.api.routes.auth.fetch_flarum_profile", fake_fetch_profile) + + response = client.post( + "/auth/flarum", + json={"flarum_user_id": "123", "flarum_token": "forum-token"}, + ) + + assert response.status_code == 200 + assert response.json()["user"]["forum_level"] == "管理员" + assert response.json()["user"]["forum_groups"] == '["管理员"]' +``` + +- [ ] **Step 3: Run auth tests and verify failure** + +Run: `.venv/bin/pytest tests/test_auth.py tests/test_flarum_service.py -v` + +Expected: FAIL because `forum_level` and `forum_groups` are not returned/mapped. + +- [ ] **Step 4: Extend auth schema** + +Modify `app/schemas/auth.py` `UserPublic`: + +```python +class UserPublic(BaseModel): + id: int + flarum_user_id: str + username: str + display_name: str + avatar_url: str + forum_level: str + forum_groups: str + + model_config = {"from_attributes": True} +``` + +- [ ] **Step 5: Store forum fields during auth** + +Modify `app/api/routes/auth.py` after avatar assignment: + +```python + user.forum_level = profile.get("forum_level", "论坛用户") + user.forum_groups = profile.get("forum_groups", "[]") +``` + +- [ ] **Step 6: Extract groups in Flarum service** + +Modify `app/services/flarum.py` to: + +1. Return user resource and attributes from `_find_included_user` instead of only attributes. +2. Extract group names from included `groups` resources referenced by user relationships. +3. Return fallback `forum_level: "论坛用户"` and `forum_groups: "[]"` when no groups are present. + +Add helper: + +```python +import json + + +def _extract_group_names(user_resource: dict, included: list[dict]) -> list[str]: + relationships = user_resource.get("relationships", {}) + groups = relationships.get("groups", {}) if isinstance(relationships, dict) else {} + group_refs = groups.get("data", []) if isinstance(groups, dict) else [] + if not isinstance(group_refs, list): + return [] + + wanted_ids = { + str(ref.get("id")) + for ref in group_refs + if isinstance(ref, dict) and ref.get("type") == "groups" and ref.get("id") is not None + } + names: list[str] = [] + for resource in included: + if not isinstance(resource, dict): + raise ValueError("malformed Flarum forum payload") + if resource.get("type") != "groups" or str(resource.get("id")) not in wanted_ids: + continue + attrs = resource.get("attributes", {}) + if not isinstance(attrs, dict): + raise ValueError("malformed Flarum forum payload") + name = attrs.get("nameSingular") or attrs.get("namePlural") or attrs.get("name") + if isinstance(name, str) and name: + names.append(name) + return names +``` + +The final return dict from `fetch_flarum_profile` must include: + +```python +"forum_level": group_names[0] if group_names else "论坛用户", +"forum_groups": json.dumps(group_names, ensure_ascii=False), +``` + +- [ ] **Step 7: Run auth tests** + +Run: `.venv/bin/pytest tests/test_auth.py tests/test_flarum_service.py -v` + +Expected: PASS. + +- [ ] **Step 8: Run full backend tests** + +Run: `.venv/bin/pytest -v` + +Expected: all tests PASS. + +- [ ] **Step 9: Commit forum profile extension** + +Run: + +```bash +git add app/services/flarum.py app/schemas/auth.py app/api/routes/auth.py tests/test_auth.py tests/test_flarum_service.py +git commit -m "feat(account): expose forum level" +``` + +Expected: commit succeeds. + +## Task 3: Add Favorite Folder API + +**Files:** +- Create: `app/schemas/favorite.py` +- Create: `app/api/routes/favorites.py` +- Modify: `app/api/router.py` +- Test: `tests/test_favorites.py` + +- [ ] **Step 1: Write failing favorite API tests** + +Create `tests/test_favorites.py` with: + +```python +from app.core.security import create_access_token +from app.models.user import User + + +def seed_user(db_session, flarum_user_id="123"): + user = User(flarum_user_id=flarum_user_id, username=f"user-{flarum_user_id}") + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +def auth_headers(user: User) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(str(user.id))}"} + + +def test_create_list_rename_and_delete_favorite_folder(client, db_session): + user = seed_user(db_session) + headers = auth_headers(user) + + created = client.post("/me/favorite-folders", headers=headers, json={"name": "办公"}) + assert created.status_code == 200 + folder_id = created.json()["id"] + assert created.json()["name"] == "办公" + assert created.json()["item_count"] == 0 + + listed = client.get("/me/favorite-folders", headers=headers) + assert [folder["name"] for folder in listed.json()] == ["办公"] + + renamed = client.patch( + f"/me/favorite-folders/{folder_id}", + headers=headers, + json={"name": "生产力"}, + ) + assert renamed.status_code == 200 + assert renamed.json()["name"] == "生产力" + + deleted = client.delete(f"/me/favorite-folders/{folder_id}", headers=headers) + assert deleted.status_code == 204 + assert client.get("/me/favorite-folders", headers=headers).json() == [] + + +def test_default_folder_created_when_adding_first_favorite(client, db_session): + user = seed_user(db_session) + headers = auth_headers(user) + + response = client.post( + "/me/favorite-folders/default/items", + headers=headers, + json={ + "app_key": "app:office:wps", + "pkgname": "wps", + "name": "WPS", + "category": "office", + "icon_url": "https://example.invalid/wps.png", + }, + ) + + assert response.status_code == 200 + assert response.json()["app_key"] == "app:office:wps" + folders = client.get("/me/favorite-folders", headers=headers).json() + assert folders[0]["name"] == "默认收藏夹" + assert folders[0]["item_count"] == 1 + + +def test_favorite_folders_are_isolated_by_user(client, db_session): + user_one = seed_user(db_session, "1") + user_two = seed_user(db_session, "2") + + assert client.post("/me/favorite-folders", headers=auth_headers(user_one), json={"name": "A"}).status_code == 200 + assert client.get("/me/favorite-folders", headers=auth_headers(user_two)).json() == [] +``` + +- [ ] **Step 2: Run favorite tests and verify failure** + +Run: `.venv/bin/pytest tests/test_favorites.py -v` + +Expected: FAIL with 404 for favorite endpoints or missing schemas. + +- [ ] **Step 3: Add favorite schemas** + +Create `app/schemas/favorite.py` with: + +```python +from datetime import datetime + +from pydantic import BaseModel, Field + + +class FavoriteFolderCreate(BaseModel): + name: str = Field(min_length=1, max_length=128) + + model_config = {"extra": "forbid"} + + +class FavoriteFolderUpdate(BaseModel): + name: str = Field(min_length=1, max_length=128) + + model_config = {"extra": "forbid"} + + +class FavoriteFolderPublic(BaseModel): + id: int + name: str + item_count: int + created_at: datetime + updated_at: datetime + + +class FavoriteItemCreate(BaseModel): + app_key: str = Field(min_length=1, max_length=512) + pkgname: str = Field(min_length=1, max_length=255) + name: str = Field(default="", max_length=255) + category: str = Field(min_length=1, max_length=128) + icon_url: str = Field(default="", max_length=1024) + + model_config = {"extra": "forbid"} + + +class FavoriteItemPublic(BaseModel): + id: int + app_key: str + pkgname: str + name: str + category: str + icon_url: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class FavoriteBulkDelete(BaseModel): + item_ids: list[int] + + model_config = {"extra": "forbid"} +``` + +- [ ] **Step 4: Add favorite routes** + +Create `app/api/routes/favorites.py` with: + +```python +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy import func, select +from sqlalchemy.orm import Session, selectinload + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.favorite import FavoriteFolder, FavoriteItem +from app.models.user import User +from app.schemas.favorite import ( + FavoriteBulkDelete, + FavoriteFolderCreate, + FavoriteFolderPublic, + FavoriteFolderUpdate, + FavoriteItemCreate, + FavoriteItemPublic, +) + +router = APIRouter() +DEFAULT_FOLDER_NAME = "默认收藏夹" + + +def folder_to_public(folder: FavoriteFolder) -> FavoriteFolderPublic: + return FavoriteFolderPublic( + id=folder.id, + name=folder.name, + item_count=len(folder.items), + created_at=folder.created_at, + updated_at=folder.updated_at, + ) + + +def get_user_folder(db: Session, user_id: int, folder_id: int) -> FavoriteFolder: + folder = db.scalar( + select(FavoriteFolder) + .options(selectinload(FavoriteFolder.items)) + .where(FavoriteFolder.id == folder_id, FavoriteFolder.user_id == user_id) + ) + if folder is None: + raise HTTPException(status_code=404, detail="Favorite folder not found") + return folder + + +def get_or_create_default_folder(db: Session, user_id: int) -> FavoriteFolder: + folder = db.scalar( + select(FavoriteFolder) + .options(selectinload(FavoriteFolder.items)) + .where(FavoriteFolder.user_id == user_id, FavoriteFolder.name == DEFAULT_FOLDER_NAME) + ) + if folder is not None: + return folder + folder = FavoriteFolder(user_id=user_id, name=DEFAULT_FOLDER_NAME) + db.add(folder) + db.flush() + return folder + + +@router.get("/me/favorite-folders", response_model=list[FavoriteFolderPublic]) +def list_favorite_folders( + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> list[FavoriteFolderPublic]: + folders = db.scalars( + select(FavoriteFolder) + .options(selectinload(FavoriteFolder.items)) + .where(FavoriteFolder.user_id == current_user.id) + .order_by(FavoriteFolder.updated_at.desc(), FavoriteFolder.id.desc()) + ).all() + return [folder_to_public(folder) for folder in folders] + + +@router.post("/me/favorite-folders", response_model=FavoriteFolderPublic) +def create_favorite_folder( + payload: FavoriteFolderCreate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> FavoriteFolderPublic: + exists = db.scalar( + select(FavoriteFolder.id).where( + FavoriteFolder.user_id == current_user.id, + FavoriteFolder.name == payload.name.strip(), + ) + ) + if exists is not None: + raise HTTPException(status_code=409, detail="Favorite folder already exists") + folder = FavoriteFolder(user_id=current_user.id, name=payload.name.strip()) + db.add(folder) + db.commit() + db.refresh(folder) + folder.items = [] + return folder_to_public(folder) + + +@router.patch("/me/favorite-folders/{folder_id}", response_model=FavoriteFolderPublic) +def rename_favorite_folder( + folder_id: int, + payload: FavoriteFolderUpdate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> FavoriteFolderPublic: + folder = get_user_folder(db, current_user.id, folder_id) + folder.name = payload.name.strip() + db.commit() + stored = get_user_folder(db, current_user.id, folder_id) + return folder_to_public(stored) + + +@router.delete("/me/favorite-folders/{folder_id}", status_code=204) +def delete_favorite_folder( + folder_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> Response: + folder = get_user_folder(db, current_user.id, folder_id) + db.delete(folder) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/me/favorite-folders/{folder_id}/items", response_model=list[FavoriteItemPublic]) +def list_favorite_items( + folder_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> list[FavoriteItem]: + folder = get_user_folder(db, current_user.id, folder_id) + return sorted(folder.items, key=lambda item: item.id) + + +@router.post("/me/favorite-folders/{folder_id}/items", response_model=FavoriteItemPublic) +def add_favorite_item( + folder_id: int | str, + payload: FavoriteItemCreate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> FavoriteItem: + folder = ( + get_or_create_default_folder(db, current_user.id) + if folder_id == "default" + else get_user_folder(db, current_user.id, int(folder_id)) + ) + item = db.scalar( + select(FavoriteItem).where( + FavoriteItem.folder_id == folder.id, + FavoriteItem.app_key == payload.app_key, + ) + ) + if item is None: + item = FavoriteItem(folder_id=folder.id, **payload.model_dump()) + db.add(item) + else: + item.pkgname = payload.pkgname + item.name = payload.name + item.category = payload.category + item.icon_url = payload.icon_url + db.commit() + db.refresh(item) + return item + + +@router.delete("/me/favorite-folders/{folder_id}/items/{item_id}", status_code=204) +def delete_favorite_item( + folder_id: int, + item_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> Response: + get_user_folder(db, current_user.id, folder_id) + item = db.get(FavoriteItem, item_id) + if item is None or item.folder_id != folder_id: + raise HTTPException(status_code=404, detail="Favorite item not found") + db.delete(item) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post("/me/favorite-folders/{folder_id}/items/bulk-delete") +def bulk_delete_favorite_items( + folder_id: int, + payload: FavoriteBulkDelete, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> dict[str, int]: + get_user_folder(db, current_user.id, folder_id) + items = db.scalars( + select(FavoriteItem).where( + FavoriteItem.folder_id == folder_id, + FavoriteItem.id.in_(payload.item_ids), + ) + ).all() + deleted_count = len(items) + for item in items: + db.delete(item) + db.commit() + return {"deleted_count": deleted_count} +``` + +- [ ] **Step 5: Include favorite router** + +Modify `app/api/router.py`: + +```python +from app.api.routes.favorites import router as favorites_router + +api_router.include_router(favorites_router) +``` + +- [ ] **Step 6: Run favorite tests** + +Run: `.venv/bin/pytest tests/test_favorites.py -v` + +Expected: PASS. + +- [ ] **Step 7: Run full backend tests** + +Run: `.venv/bin/pytest -v` + +Expected: all tests PASS. + +- [ ] **Step 8: Commit favorite API** + +Run: + +```bash +git add app/schemas/favorite.py app/api/routes/favorites.py app/api/router.py tests/test_favorites.py +git commit -m "feat(account): add favorite folders api" +``` + +Expected: commit succeeds. + +## Task 4: Add Downloaded Apps API + +**Files:** +- Create: `app/schemas/downloaded_app.py` +- Create: `app/api/routes/downloaded_apps.py` +- Modify: `app/api/router.py` +- Test: `tests/test_downloaded_apps.py` + +- [ ] **Step 1: Write failing downloaded app API tests** + +Create `tests/test_downloaded_apps.py` with: + +```python +from app.core.security import create_access_token +from app.models.user import User + + +def seed_user(db_session, flarum_user_id="123"): + user = User(flarum_user_id=flarum_user_id, username=f"user-{flarum_user_id}") + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +def auth_headers(user: User) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(str(user.id))}"} + + +def test_create_and_list_downloaded_apps(client, db_session): + user = seed_user(db_session) + headers = auth_headers(user) + + created = client.post( + "/me/downloaded-apps", + headers=headers, + json={ + "app_key": "app:office:wps", + "pkgname": "wps", + "name": "WPS", + "category": "office", + "selected_origin": "apm", + "version": "1.0.0", + "package_arch": "amd64", + }, + ) + + assert created.status_code == 200 + assert created.json()["selected_origin"] == "apm" + + listed = client.get("/me/downloaded-apps", headers=headers) + assert listed.status_code == 200 + assert listed.json()["items"][0]["pkgname"] == "wps" + assert listed.json()["total"] == 1 + + +def test_downloaded_apps_are_isolated_by_user(client, db_session): + user_one = seed_user(db_session, "1") + user_two = seed_user(db_session, "2") + response = client.post( + "/me/downloaded-apps", + headers=auth_headers(user_one), + json={ + "app_key": "app:office:wps", + "pkgname": "wps", + "name": "WPS", + "category": "office", + "selected_origin": "apm", + "version": "1.0.0", + "package_arch": "amd64", + }, + ) + assert response.status_code == 200 + + listed = client.get("/me/downloaded-apps", headers=auth_headers(user_two)) + assert listed.json()["items"] == [] + assert listed.json()["total"] == 0 +``` + +- [ ] **Step 2: Run downloaded app tests and verify failure** + +Run: `.venv/bin/pytest tests/test_downloaded_apps.py -v` + +Expected: FAIL with 404 for `/me/downloaded-apps`. + +- [ ] **Step 3: Add downloaded app schemas** + +Create `app/schemas/downloaded_app.py` with: + +```python +from datetime import datetime + +from pydantic import BaseModel, Field + + +class DownloadedAppCreate(BaseModel): + app_key: str = Field(min_length=1, max_length=512) + pkgname: str = Field(min_length=1, max_length=255) + name: str = Field(default="", max_length=255) + category: str = Field(min_length=1, max_length=128) + selected_origin: str = Field(min_length=1, max_length=16) + version: str = Field(default="", max_length=255) + package_arch: str = Field(default="", max_length=64) + + model_config = {"extra": "forbid"} + + +class DownloadedAppPublic(BaseModel): + id: int + app_key: str + pkgname: str + name: str + category: str + selected_origin: str + version: str + package_arch: str + downloaded_at: datetime + + model_config = {"from_attributes": True} + + +class DownloadedAppList(BaseModel): + items: list[DownloadedAppPublic] + total: int + page: int + page_size: int +``` + +- [ ] **Step 4: Add downloaded app routes** + +Create `app/api/routes/downloaded_apps.py` with: + +```python +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.downloaded_app import DownloadedApp +from app.models.user import User +from app.schemas.downloaded_app import DownloadedAppCreate, DownloadedAppList, DownloadedAppPublic + +router = APIRouter() + + +@router.post("/me/downloaded-apps", response_model=DownloadedAppPublic) +def create_downloaded_app( + payload: DownloadedAppCreate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> DownloadedApp: + record = DownloadedApp(user_id=current_user.id, **payload.model_dump()) + db.add(record) + db.commit() + db.refresh(record) + return record + + +@router.get("/me/downloaded-apps", response_model=DownloadedAppList) +def list_downloaded_apps( + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 20, +) -> DownloadedAppList: + total = db.scalar( + select(func.count(DownloadedApp.id)).where(DownloadedApp.user_id == current_user.id) + ) or 0 + items = db.scalars( + select(DownloadedApp) + .where(DownloadedApp.user_id == current_user.id) + .order_by(DownloadedApp.downloaded_at.desc(), DownloadedApp.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ).all() + return DownloadedAppList(items=list(items), total=total, page=page, page_size=page_size) +``` + +- [ ] **Step 5: Include downloaded app router** + +Modify `app/api/router.py`: + +```python +from app.api.routes.downloaded_apps import router as downloaded_apps_router + +api_router.include_router(downloaded_apps_router) +``` + +- [ ] **Step 6: Run downloaded app tests** + +Run: `.venv/bin/pytest tests/test_downloaded_apps.py -v` + +Expected: PASS. + +- [ ] **Step 7: Run full backend tests** + +Run: `.venv/bin/pytest -v` + +Expected: all tests PASS. + +- [ ] **Step 8: Commit downloaded app API** + +Run: + +```bash +git add app/schemas/downloaded_app.py app/api/routes/downloaded_apps.py app/api/router.py tests/test_downloaded_apps.py +git commit -m "feat(account): add downloaded apps api" +``` + +Expected: commit succeeds. + +## Task 5: Update Backend API And Deployment Docs + +**Files:** +- Modify: `docs/api.md` +- Modify: `docs/deployment.md` +- Modify: `README.md` + +- [ ] **Step 1: Document user profile fields** + +Modify `docs/api.md` auth response examples so `user` includes: + +```json +{ + "forum_level": "管理员", + "forum_groups": "[\"管理员\"]" +} +``` + +Include text: `forum_level` falls back to `论坛用户` when Flarum group data is unavailable. + +- [ ] **Step 2: Document favorite endpoints** + +Append to `docs/api.md`: + +```markdown +## Favorites + +All favorite endpoints require bearer JWT. Favorites store app-level identities in the format `app:{category}:{pkgname}` and do not permanently bind to Spark or APM. + +### `GET /me/favorite-folders` + +Returns folders with `item_count`. + +### `POST /me/favorite-folders` + +Request: `{ "name": "办公" }`. + +### `PATCH /me/favorite-folders/{folder_id}` + +Request: `{ "name": "生产力" }`. + +### `DELETE /me/favorite-folders/{folder_id}` + +Deletes a folder and its items. + +### `GET /me/favorite-folders/{folder_id}/items` + +Returns favorite items. + +### `POST /me/favorite-folders/{folder_id}/items` + +Use `folder_id` or `default`. `default` creates `默认收藏夹` when needed. + +Request: +```json +{ + "app_key": "app:office:wps", + "pkgname": "wps", + "name": "WPS", + "category": "office", + "icon_url": "https://example.invalid/wps.png" +} +``` + +### `DELETE /me/favorite-folders/{folder_id}/items/{item_id}` + +Removes one item. + +### `POST /me/favorite-folders/{folder_id}/items/bulk-delete` + +Request: `{ "item_ids": [1, 2, 3] }`. +``` +``` + +- [ ] **Step 3: Document downloaded endpoints** + +Append to `docs/api.md`: + +```markdown +## Downloaded Apps + +All downloaded-app endpoints require bearer JWT. + +### `GET /me/downloaded-apps` + +Query parameters: `page`, `page_size`. + +### `POST /me/downloaded-apps` + +Request: +```json +{ + "app_key": "app:office:wps", + "pkgname": "wps", + "name": "WPS", + "category": "office", + "selected_origin": "apm", + "version": "1.0.0", + "package_arch": "amd64" +} +``` +``` +``` + +- [ ] **Step 4: Update deployment guide** + +Modify `docs/deployment.md` upgrade section to mention that deployments must run Alembic after pulling because account collection tables are included in the initial schema for fresh installs. + +- [ ] **Step 5: Run docs-adjacent verification** + +Run: + +```bash +.venv/bin/pytest -v +DATABASE_URL="sqlite:////tmp/opencode/spark-backend-account-docs.sqlite3" .venv/bin/alembic upgrade head +``` + +Expected: tests PASS and Alembic exits 0. + +- [ ] **Step 6: Commit docs** + +Run: + +```bash +git add docs/api.md docs/deployment.md README.md +git commit -m "docs(account): document collection endpoints" +``` + +Expected: commit succeeds if files changed. + +## Task 6: Final Backend Verification And Push + +**Files:** +- Verify only. + +- [ ] **Step 1: Run full backend tests** + +Run: `.venv/bin/pytest -v` + +Expected: all tests PASS. + +- [ ] **Step 2: Verify migration on a fresh database URL** + +Run: + +```bash +DATABASE_URL="sqlite:////tmp/opencode/spark-backend-account-final.sqlite3" .venv/bin/alembic upgrade head +``` + +Expected: command exits 0. + +- [ ] **Step 3: Check git status** + +Run: `git status --short --branch` + +Expected: branch is clean and ahead of `origin/master` by the new backend commits. + +- [ ] **Step 4: Push backend** + +Run: `git push origin master` + +Expected: push succeeds to `https://gitee.com/erotica-rbqs/spark-unionid-server`. If authentication fails, report exact error and leave local repo intact. + +## Self-Review Checklist + +Spec coverage: + +- Forum level data: Task 2. +- Favorite folders/items and default folder: Tasks 1 and 3. +- Downloaded records: Tasks 1 and 4. +- API/deployment docs: Task 5. +- Final backend verification and push: Task 6. + +Placeholder scan: + +- No placeholder sections remain. Tests, request payloads, and implementation snippets are concrete. + +Type consistency: + +- Favorite app key uses `app:{category}:{pkgname}`. +- Backend field names remain snake_case. +- `forum_groups` is a JSON-encoded string to keep the existing response schema simple. diff --git a/docs/superpowers/plans/2026-05-18-spark-backend-account-reviews-sync.md b/docs/superpowers/plans/2026-05-18-spark-backend-account-reviews-sync.md new file mode 100644 index 00000000..6e774794 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-spark-backend-account-reviews-sync.md @@ -0,0 +1,1668 @@ +# Spark Backend Account Reviews Sync Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the new `spark-store-backend` FastAPI + MySQL service for Flarum-backed login, app reviews/ratings, and default user app-list sync. + +**Architecture:** The backend validates Flarum access tokens, maps Flarum users to local users, signs Spark Store JWTs, and persists Spark Store-specific data in MySQL. The API uses synchronous FastAPI routes, SQLAlchemy ORM, Alembic migrations, and pytest API tests with a SQLite test database. + +**Tech Stack:** Python 3.11+, FastAPI, SQLAlchemy 2.x, Alembic, PyMySQL, Pydantic Settings, python-jose, httpx, pytest. + +--- + +## File Structure + +Create a new sibling repository at `/home/spark/Desktop/shenmo-spark-store/spark-store-backend`. + +Backend files: + +- Create: `README.md` - project overview, setup, test commands, API summary. +- Create: `.gitignore` - Python, virtualenv, test, and secret ignores. +- Create: `.env.example` - safe configuration template. +- Create: `pyproject.toml` - dependencies, pytest config, tooling config. +- Create: `alembic.ini` - Alembic config. +- Create: `alembic/env.py` - migration environment wired to app metadata. +- Create: `alembic/versions/0001_initial.py` - initial MySQL schema. +- Create: `app/main.py` - FastAPI app factory and router registration. +- Create: `app/core/config.py` - environment settings. +- Create: `app/core/security.py` - JWT creation and decoding. +- Create: `app/db/session.py` - SQLAlchemy engine/session dependency. +- Create: `app/db/base.py` - declarative base and model imports. +- Create: `app/models/user.py` - `User` ORM model. +- Create: `app/models/store_app.py` - `StoreApp` ORM model. +- Create: `app/models/review.py` - `Review` ORM model. +- Create: `app/models/app_list.py` - app-list ORM models. +- Create: `app/schemas/auth.py` - auth request/response schemas. +- Create: `app/schemas/review.py` - review request/response schemas. +- Create: `app/schemas/app_list.py` - app-list request/response schemas. +- Create: `app/services/flarum.py` - Flarum profile validation client. +- Create: `app/api/deps.py` - auth and DB dependencies. +- Create: `app/api/router.py` - API router aggregator. +- Create: `app/api/routes/auth.py` - auth endpoints. +- Create: `app/api/routes/reviews.py` - review and rating endpoints. +- Create: `app/api/routes/app_lists.py` - app-list endpoints. +- Create: `tests/conftest.py` - test app and database fixtures. +- Create: `tests/test_auth.py` - Flarum auth tests. +- Create: `tests/test_reviews.py` - review behavior tests. +- Create: `tests/test_app_lists.py` - app-list sync tests. + +## Task 1: Initialize Backend Repository + +**Files:** +- Create: `/home/spark/Desktop/shenmo-spark-store/spark-store-backend/README.md` +- Create: `/home/spark/Desktop/shenmo-spark-store/spark-store-backend/.gitignore` + +- [ ] **Step 1: Verify parent directory exists** + +Run: `ls "/home/spark/Desktop/shenmo-spark-store"` + +Expected: output includes `spark-store`. + +- [ ] **Step 2: Create repository directory** + +Run: `mkdir "/home/spark/Desktop/shenmo-spark-store/spark-store-backend"` + +Expected: command exits 0. + +- [ ] **Step 3: Initialize Git repository** + +Run: `git init` + +Workdir: `/home/spark/Desktop/shenmo-spark-store/spark-store-backend` + +Expected: output says an empty Git repository was initialized. + +- [ ] **Step 4: Write initial README** + +Create `README.md` with: + +```markdown +# Spark Store Backend + +Python backend for Spark Store account login, app reviews, ratings, and user app-list sync. + +The service uses the Spark forum Flarum instance as the identity provider and stores Spark Store-specific data in MySQL. + +## Development + +```bash +python -m venv .venv +. .venv/bin/activate +pip install -e ".[dev]" +pytest +``` +``` + +- [ ] **Step 5: Write `.gitignore`** + +Create `.gitignore` with: + +```gitignore +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +htmlcov/ +.env +*.sqlite3 +dist/ +build/ +*.egg-info/ +``` + +- [ ] **Step 6: Commit initial repository** + +Run: + +```bash +git add README.md .gitignore +git commit -m "first commit" +git remote add origin https://gitee.com/momen_official/spark-store-backend.git +``` + +Expected: commit succeeds and `git remote -v` shows the Gitee origin. + +## Task 2: Add Backend Project Skeleton + +**Files:** +- Create: `pyproject.toml` +- Create: `.env.example` +- Create: `app/__init__.py` +- Create: `app/main.py` +- Create: `app/core/config.py` +- Create: `app/api/router.py` +- Test: `tests/test_health.py` + +- [ ] **Step 1: Write failing health test** + +Create `tests/test_health.py` with: + +```python +from fastapi.testclient import TestClient + +from app.main import create_app + + +def test_health_returns_ok(): + client = TestClient(create_app()) + + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} +``` + +- [ ] **Step 2: Run test and verify failure** + +Run: `pytest tests/test_health.py -v` + +Expected: FAIL with `ModuleNotFoundError: No module named 'app'`. + +- [ ] **Step 3: Add package config** + +Create `pyproject.toml` with: + +```toml +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "spark-store-backend" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115,<1.0", + "uvicorn[standard]>=0.30,<1.0", + "sqlalchemy>=2.0,<3.0", + "alembic>=1.13,<2.0", + "pymysql>=1.1,<2.0", + "pydantic-settings>=2.4,<3.0", + "python-jose[cryptography]>=3.3,<4.0", + "httpx>=0.27,<1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3,<9.0", + "pytest-cov>=5.0,<6.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +``` + +- [ ] **Step 4: Add settings** + +Create `.env.example` with: + +```env +APP_NAME="Spark Store Backend" +DATABASE_URL="mysql+pymysql://spark:spark_password@127.0.0.1:3306/spark_store" +JWT_SECRET_KEY="change-me-in-production" +JWT_ALGORITHM="HS256" +JWT_EXPIRE_MINUTES="10080" +FLARUM_BASE_URL="https://bbs.spark-app.store" +``` + +Create `app/core/config.py` with: + +```python +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_name: str = "Spark Store Backend" + database_url: str = "sqlite:///./spark-store-dev.sqlite3" + jwt_secret_key: str = "dev-secret" + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = 10080 + flarum_base_url: str = "https://bbs.spark-app.store" + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + +@lru_cache +def get_settings() -> Settings: + return Settings() +``` + +- [ ] **Step 5: Add FastAPI app** + +Create `app/__init__.py` as an empty file. + +Create `app/api/router.py` with: + +```python +from fastapi import APIRouter + +api_router = APIRouter() +``` + +Create `app/main.py` with: + +```python +from fastapi import FastAPI + +from app.api.router import api_router +from app.core.config import get_settings + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI(title=settings.app_name) + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ok"} + + app.include_router(api_router) + return app + + +app = create_app() +``` + +- [ ] **Step 6: Run test and verify pass** + +Run: `pytest tests/test_health.py -v` + +Expected: PASS. + +- [ ] **Step 7: Commit skeleton** + +Run: + +```bash +git add . +git commit -m "feat: add FastAPI backend skeleton" +``` + +Expected: commit succeeds. + +## Task 3: Add Database Models And Migration + +**Files:** +- Create: `app/db/session.py` +- Create: `app/db/base.py` +- Create: `app/models/user.py` +- Create: `app/models/store_app.py` +- Create: `app/models/review.py` +- Create: `app/models/app_list.py` +- Create: `alembic.ini` +- Create: `alembic/env.py` +- Create: `alembic/versions/0001_initial.py` +- Test: `tests/test_models.py` + +- [ ] **Step 1: Write failing model test** + +Create `tests/test_models.py` with: + +```python +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.db.base import Base +from app.models.user import User +from app.models.store_app import StoreApp +from app.models.review import Review + + +def test_review_model_persists_tagged_rating(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + + with Session(engine) as session: + user = User(flarum_user_id="123", username="momen", display_name="Momen") + app = StoreApp( + app_key="apm:amd64-apm:office:wps", + pkgname="wps", + origin="apm", + store_arch="amd64-apm", + category="office", + latest_seen_version="1.0.0", + ) + session.add_all([user, app]) + session.flush() + + review = Review( + user_id=user.id, + app_id=app.id, + rating=5, + content="Works well.", + version="1.0.0", + package_arch="amd64", + client_arch="amd64", + distro="deepin 25", + origin="apm", + category="office", + ) + session.add(review) + session.commit() + + stored = session.query(Review).one() + assert stored.rating == 5 + assert stored.version == "1.0.0" + assert stored.distro == "deepin 25" +``` + +- [ ] **Step 2: Run test and verify failure** + +Run: `pytest tests/test_models.py -v` + +Expected: FAIL with missing `app.db.base` or models. + +- [ ] **Step 3: Add database session and base** + +Create `app/db/session.py` with: + +```python +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import get_settings + +settings = get_settings() +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() +``` + +Create `app/db/base.py` with: + +```python +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass + + +from app.models.app_list import UserAppList, UserAppListItem # noqa: E402,F401 +from app.models.review import Review # noqa: E402,F401 +from app.models.store_app import StoreApp # noqa: E402,F401 +from app.models.user import User # noqa: E402,F401 +``` + +- [ ] **Step 4: Add ORM models** + +Create `app/models/user.py` with: + +```python +from datetime import datetime, timezone + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + flarum_user_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + username: Mapped[str] = mapped_column(String(128), default="") + display_name: Mapped[str] = mapped_column(String(128), default="") + avatar_url: Mapped[str] = mapped_column(String(1024), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + reviews = relationship("Review", back_populates="user") + app_lists = relationship("UserAppList", back_populates="user") +``` + +Create `app/models/store_app.py` with: + +```python +from datetime import datetime, timezone + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class StoreApp(Base): + __tablename__ = "apps" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + app_key: Mapped[str] = mapped_column(String(512), unique=True, index=True) + pkgname: Mapped[str] = mapped_column(String(255), index=True) + origin: Mapped[str] = mapped_column(String(16), index=True) + store_arch: Mapped[str] = mapped_column(String(64), index=True) + category: Mapped[str] = mapped_column(String(128), index=True) + latest_seen_version: Mapped[str] = mapped_column(String(255), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + reviews = relationship("Review", back_populates="app") +``` + +Create `app/models/review.py` with: + +```python +from datetime import datetime, timezone + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class Review(Base): + __tablename__ = "reviews" + __table_args__ = ( + CheckConstraint("rating >= 1 AND rating <= 5", name="ck_reviews_rating_range"), + UniqueConstraint( + "user_id", + "app_id", + "version", + "package_arch", + "client_arch", + "distro", + "origin", + "category", + name="uq_reviews_user_app_tags", + ), + Index("ix_reviews_app_filters", "app_id", "version", "package_arch", "client_arch", "distro", "origin", "category"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + app_id: Mapped[int] = mapped_column(ForeignKey("apps.id"), index=True) + rating: Mapped[int] + content: Mapped[str] = mapped_column(Text) + version: Mapped[str] = mapped_column(String(255), default="unknown") + package_arch: Mapped[str] = mapped_column(String(64), default="unknown") + client_arch: Mapped[str] = mapped_column(String(64), default="unknown") + distro: Mapped[str] = mapped_column(String(255), default="unknown") + origin: Mapped[str] = mapped_column(String(16), default="") + category: Mapped[str] = mapped_column(String(128), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + user = relationship("User", back_populates="reviews") + app = relationship("StoreApp", back_populates="reviews") +``` + +Create `app/models/app_list.py` with: + +```python +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class UserAppList(Base): + __tablename__ = "user_app_lists" + __table_args__ = (UniqueConstraint("user_id", "snapshot_name", name="uq_user_app_lists_default"),) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + snapshot_name: Mapped[str] = mapped_column(String(128), default="default") + client_arch: Mapped[str] = mapped_column(String(64), default="unknown") + distro: Mapped[str] = mapped_column(String(255), default="unknown") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + user = relationship("User", back_populates="app_lists") + items = relationship("UserAppListItem", back_populates="app_list", cascade="all, delete-orphan") + + +class UserAppListItem(Base): + __tablename__ = "user_app_list_items" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + list_id: Mapped[int] = mapped_column(ForeignKey("user_app_lists.id"), index=True) + pkgname: Mapped[str] = mapped_column(String(255), index=True) + origin: Mapped[str] = mapped_column(String(16), index=True) + category: Mapped[str] = mapped_column(String(128), index=True) + version: Mapped[str] = mapped_column(String(255), default="") + package_arch: Mapped[str] = mapped_column(String(64), default="unknown") + app_name: Mapped[str] = mapped_column(String(255), default="") + icon_url: Mapped[str] = mapped_column(String(1024), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + app_list = relationship("UserAppList", back_populates="items") +``` + +- [ ] **Step 5: Run model test and verify pass** + +Run: `pytest tests/test_models.py -v` + +Expected: PASS. + +- [ ] **Step 6: Add Alembic migration** + +Create `alembic.ini` with: + +```ini +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = sqlite:///./spark-store-dev.sqlite3 + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S +``` + +Create `alembic/env.py` with: + +```python +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import get_settings +from app.db.base import Base + +config = context.config +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() +``` + +Create `alembic/versions/0001_initial.py` with: + +```python +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0001_initial" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("flarum_user_id", sa.String(length=64), nullable=False), + sa.Column("username", sa.String(length=128), nullable=False), + sa.Column("display_name", sa.String(length=128), nullable=False), + sa.Column("avatar_url", sa.String(length=1024), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("flarum_user_id"), + ) + op.create_index("ix_users_flarum_user_id", "users", ["flarum_user_id"]) + + op.create_table( + "apps", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("app_key", sa.String(length=512), nullable=False), + sa.Column("pkgname", sa.String(length=255), nullable=False), + sa.Column("origin", sa.String(length=16), nullable=False), + sa.Column("store_arch", sa.String(length=64), nullable=False), + sa.Column("category", sa.String(length=128), nullable=False), + sa.Column("latest_seen_version", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("app_key"), + ) + op.create_index("ix_apps_app_key", "apps", ["app_key"]) + op.create_index("ix_apps_pkgname", "apps", ["pkgname"]) + op.create_index("ix_apps_origin", "apps", ["origin"]) + op.create_index("ix_apps_store_arch", "apps", ["store_arch"]) + op.create_index("ix_apps_category", "apps", ["category"]) + + op.create_table( + "reviews", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("app_id", sa.Integer(), sa.ForeignKey("apps.id"), nullable=False), + sa.Column("rating", sa.Integer(), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("version", sa.String(length=255), nullable=False), + sa.Column("package_arch", sa.String(length=64), nullable=False), + sa.Column("client_arch", sa.String(length=64), nullable=False), + sa.Column("distro", sa.String(length=255), nullable=False), + sa.Column("origin", sa.String(length=16), nullable=False), + sa.Column("category", sa.String(length=128), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("rating >= 1 AND rating <= 5", name="ck_reviews_rating_range"), + sa.UniqueConstraint("user_id", "app_id", "version", "package_arch", "client_arch", "distro", "origin", "category", name="uq_reviews_user_app_tags"), + ) + op.create_index("ix_reviews_user_id", "reviews", ["user_id"]) + op.create_index("ix_reviews_app_id", "reviews", ["app_id"]) + op.create_index("ix_reviews_app_filters", "reviews", ["app_id", "version", "package_arch", "client_arch", "distro", "origin", "category"]) + + op.create_table( + "user_app_lists", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("snapshot_name", sa.String(length=128), nullable=False), + sa.Column("client_arch", sa.String(length=64), nullable=False), + sa.Column("distro", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("user_id", "snapshot_name", name="uq_user_app_lists_default"), + ) + op.create_index("ix_user_app_lists_user_id", "user_app_lists", ["user_id"]) + + op.create_table( + "user_app_list_items", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("list_id", sa.Integer(), sa.ForeignKey("user_app_lists.id"), nullable=False), + sa.Column("pkgname", sa.String(length=255), nullable=False), + sa.Column("origin", sa.String(length=16), nullable=False), + sa.Column("category", sa.String(length=128), nullable=False), + sa.Column("version", sa.String(length=255), nullable=False), + sa.Column("package_arch", sa.String(length=64), nullable=False), + sa.Column("app_name", sa.String(length=255), nullable=False), + sa.Column("icon_url", sa.String(length=1024), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_user_app_list_items_list_id", "user_app_list_items", ["list_id"]) + op.create_index("ix_user_app_list_items_pkgname", "user_app_list_items", ["pkgname"]) + op.create_index("ix_user_app_list_items_origin", "user_app_list_items", ["origin"]) + op.create_index("ix_user_app_list_items_category", "user_app_list_items", ["category"]) + + +def downgrade() -> None: + op.drop_table("user_app_list_items") + op.drop_table("user_app_lists") + op.drop_table("reviews") + op.drop_table("apps") + op.drop_table("users") +``` + +Run: `alembic upgrade head` + +Expected: local development database receives revision `0001_initial`. + +- [ ] **Step 7: Commit database layer** + +Run: + +```bash +git add app/db app/models alembic.ini alembic tests/test_models.py +git commit -m "feat: add database schema" +``` + +Expected: commit succeeds. + +## Task 4: Implement Flarum Auth And JWT + +**Files:** +- Create: `app/core/security.py` +- Create: `app/services/flarum.py` +- Create: `app/schemas/auth.py` +- Create: `app/api/deps.py` +- Create: `app/api/routes/auth.py` +- Modify: `app/api/router.py` +- Test: `tests/conftest.py` +- Test: `tests/test_auth.py` + +- [ ] **Step 1: Write failing auth tests** + +Create `tests/conftest.py` with a SQLite test database, dependency override for `get_db`, and `TestClient(create_app())` fixture. + +Create `tests/test_auth.py` with: + +```python +from app.models.user import User + + +def test_auth_flarum_creates_user_and_returns_jwt(client, monkeypatch): + async def fake_fetch_profile(token: str, user_id: str): + assert token == "forum-token" + assert user_id == "123" + return { + "flarum_user_id": "123", + "username": "momen", + "display_name": "Momen", + "avatar_url": "https://bbs.spark-app.store/avatar.png", + } + + monkeypatch.setattr("app.api.routes.auth.fetch_flarum_profile", fake_fetch_profile) + + response = client.post("/auth/flarum", json={"flarum_user_id": "123", "flarum_token": "forum-token"}) + + assert response.status_code == 200 + data = response.json() + assert data["token_type"] == "bearer" + assert data["access_token"] + assert data["user"]["display_name"] == "Momen" + + +def test_me_requires_jwt(client): + response = client.get("/me") + assert response.status_code == 401 +``` + +- [ ] **Step 2: Run auth tests and verify failure** + +Run: `pytest tests/test_auth.py -v` + +Expected: FAIL with missing auth routes or schemas. + +- [ ] **Step 3: Add JWT helpers** + +Create `app/core/security.py` with: + +```python +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt + +from app.core.config import get_settings + + +def create_access_token(subject: str) -> str: + settings = get_settings() + expires = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) + payload = {"sub": subject, "exp": expires} + return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> str | None: + settings = get_settings() + try: + payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]) + except JWTError: + return None + subject = payload.get("sub") + return subject if isinstance(subject, str) else None +``` + +- [ ] **Step 4: Add auth schemas and Flarum service** + +Create `app/schemas/auth.py` with: + +```python +from pydantic import BaseModel, Field + + +class FlarumAuthRequest(BaseModel): + flarum_user_id: str = Field(min_length=1, max_length=64) + flarum_token: str = Field(min_length=1) + + +class UserPublic(BaseModel): + id: int + flarum_user_id: str + username: str + display_name: str + avatar_url: str + + model_config = {"from_attributes": True} + + +class AuthResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserPublic +``` + +Create `app/services/flarum.py` with: + +```python +import httpx + +from app.core.config import get_settings + + +async def fetch_flarum_profile(token: str, user_id: str) -> dict[str, str]: + settings = get_settings() + url = f"{settings.flarum_base_url.rstrip('/')}/api/users/{user_id}" + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(url, headers={"Authorization": f"Token {token}"}) + + if response.status_code != 200: + raise ValueError("invalid flarum token") + + data = response.json()["data"] + attrs = data.get("attributes", {}) + return { + "flarum_user_id": str(data.get("id", user_id)), + "username": attrs.get("username") or "", + "display_name": attrs.get("displayName") or attrs.get("username") or "", + "avatar_url": attrs.get("avatarUrl") or "", + } +``` + +- [ ] **Step 5: Add auth dependency and routes** + +Create `app/api/deps.py` with: + +```python +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from app.core.security import decode_access_token +from app.db.session import get_db +from app.models.user import User + +bearer = HTTPBearer(auto_error=False) + + +def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], + db: Annotated[Session, Depends(get_db)], +) -> User: + if credentials is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing bearer token") + subject = decode_access_token(credentials.credentials) + if subject is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid bearer token") + user = db.get(User, int(subject)) if subject.isdigit() else None + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") + return user +``` + +Create `app/api/routes/auth.py` with: + +```python +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.core.security import create_access_token +from app.db.session import get_db +from app.models.user import User +from app.schemas.auth import AuthResponse, FlarumAuthRequest, UserPublic +from app.services.flarum import fetch_flarum_profile + +router = APIRouter(tags=["auth"]) + + +@router.post("/auth/flarum", response_model=AuthResponse) +async def auth_flarum(payload: FlarumAuthRequest, db: Session = Depends(get_db)) -> AuthResponse: + try: + profile = await fetch_flarum_profile(payload.flarum_token, payload.flarum_user_id) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid flarum token") from exc + + user = db.scalar(select(User).where(User.flarum_user_id == profile["flarum_user_id"])) + if user is None: + user = User(**profile) + db.add(user) + else: + user.username = profile["username"] + user.display_name = profile["display_name"] + user.avatar_url = profile["avatar_url"] + db.commit() + db.refresh(user) + + return AuthResponse(access_token=create_access_token(str(user.id)), user=UserPublic.model_validate(user)) + + +@router.get("/me", response_model=UserPublic) +def me(user: User = Depends(get_current_user)) -> User: + return user +``` + +Modify `app/api/router.py` to include: + +```python +from fastapi import APIRouter + +from app.api.routes import auth + +api_router = APIRouter() +api_router.include_router(auth.router) +``` + +- [ ] **Step 6: Run auth tests and verify pass** + +Run: `pytest tests/test_auth.py -v` + +Expected: PASS. + +- [ ] **Step 7: Commit auth** + +Run: + +```bash +git add app tests/test_auth.py tests/conftest.py +git commit -m "feat: add Flarum auth" +``` + +Expected: commit succeeds. + +## Task 5: Implement Reviews And Rating Summary + +**Files:** +- Create: `app/schemas/review.py` +- Create: `app/api/routes/reviews.py` +- Modify: `app/api/router.py` +- Test: `tests/test_reviews.py` + +- [ ] **Step 1: Write failing review tests** + +Create `tests/test_reviews.py` with: + +```python +from app.core.security import create_access_token +from app.models.user import User + + +def seed_user(db_session): + user = User(flarum_user_id="123", username="momen", display_name="Momen", avatar_url="") + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +def auth_headers(user: User) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(str(user.id))}"} + + +def test_create_review_and_rating_summary(client, db_session): + user = seed_user(db_session) + + response = client.post( + "/apps/apm:amd64-apm:office:wps/reviews", + headers=auth_headers(user), + json={ + "rating": 5, + "content": "Works well.", + "tags": { + "origin": "apm", + "category": "office", + "pkgname": "wps", + "version": "1.0.0", + "package_arch": "amd64", + "client_arch": "amd64", + "distro": "deepin 25", + }, + }, + ) + + assert response.status_code == 200 + assert response.json()["rating"] == 5 + + summary = client.get("/apps/apm:amd64-apm:office:wps/rating-summary") + assert summary.status_code == 200 + assert summary.json()["average_rating"] == 5.0 + assert summary.json()["review_count"] == 1 + assert summary.json()["star_counts"]["5"] == 1 + + +def test_reviews_filter_by_version(client, db_session): + user = seed_user(db_session) + headers = auth_headers(user) + + for version in ["1.0.0", "2.0.0"]: + response = client.post( + "/apps/apm:amd64-apm:office:wps/reviews", + headers=headers, + json={ + "rating": 4, + "content": f"Version {version}", + "tags": { + "origin": "apm", + "category": "office", + "pkgname": "wps", + "version": version, + "package_arch": "amd64", + "client_arch": "amd64", + "distro": "deepin 25", + }, + }, + ) + assert response.status_code == 200 + + response = client.get("/apps/apm:amd64-apm:office:wps/reviews", params={"version": "2.0.0"}) + + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["version"] == "2.0.0" +``` + +- [ ] **Step 2: Run review tests and verify failure** + +Run: `pytest tests/test_reviews.py -v` + +Expected: FAIL with `404 Not Found` for review endpoints. + +- [ ] **Step 3: Add review schemas** + +Create `app/schemas/review.py` with: + +```python +from datetime import datetime + +from pydantic import BaseModel, Field + + +class ReviewTags(BaseModel): + origin: str = Field(min_length=1, max_length=16) + category: str = Field(min_length=1, max_length=128) + pkgname: str = Field(min_length=1, max_length=255) + version: str = Field(default="unknown", max_length=255) + package_arch: str = Field(default="unknown", max_length=64) + client_arch: str = Field(default="unknown", max_length=64) + distro: str = Field(default="unknown", max_length=255) + + +class ReviewCreate(BaseModel): + rating: int = Field(ge=1, le=5) + content: str = Field(min_length=1, max_length=5000) + tags: ReviewTags + + +class ReviewPublic(BaseModel): + id: int + rating: int + content: str + version: str + package_arch: str + client_arch: str + distro: str + origin: str + category: str + created_at: datetime + updated_at: datetime + user_display_name: str + user_avatar_url: str + + +class RatingSummary(BaseModel): + average_rating: float + review_count: int + star_counts: dict[int, int] +``` + +- [ ] **Step 4: Add review routes** + +Create `app/api/routes/reviews.py` with: + +```python +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.review import Review +from app.models.store_app import StoreApp +from app.models.user import User +from app.schemas.review import RatingSummary, ReviewCreate, ReviewPublic + +router = APIRouter(tags=["reviews"]) + + +def parse_app_key(app_key: str) -> dict[str, str]: + parts = app_key.split(":", 3) + if len(parts) != 4: + raise HTTPException(status_code=422, detail="invalid app key") + origin, store_arch, category, pkgname = parts + return {"origin": origin, "store_arch": store_arch, "category": category, "pkgname": pkgname} + + +def get_or_create_app(db: Session, app_key: str) -> StoreApp: + parsed = parse_app_key(app_key) + app = db.scalar(select(StoreApp).where(StoreApp.app_key == app_key)) + if app is not None: + return app + app = StoreApp(app_key=app_key, latest_seen_version="", **parsed) + db.add(app) + db.flush() + return app + + +def to_public(review: Review) -> ReviewPublic: + return ReviewPublic( + id=review.id, + rating=review.rating, + content=review.content, + version=review.version, + package_arch=review.package_arch, + client_arch=review.client_arch, + distro=review.distro, + origin=review.origin, + category=review.category, + created_at=review.created_at, + updated_at=review.updated_at, + user_display_name=review.user.display_name, + user_avatar_url=review.user.avatar_url, + ) + + +@router.post("/apps/{app_key}/reviews", response_model=ReviewPublic) +def upsert_review( + app_key: str, + payload: ReviewCreate, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> ReviewPublic: + app = get_or_create_app(db, app_key) + tags = payload.tags + review = db.scalar( + select(Review).where( + Review.user_id == user.id, + Review.app_id == app.id, + Review.version == tags.version, + Review.package_arch == tags.package_arch, + Review.client_arch == tags.client_arch, + Review.distro == tags.distro, + Review.origin == tags.origin, + Review.category == tags.category, + ) + ) + if review is None: + review = Review(user_id=user.id, app_id=app.id) + db.add(review) + review.rating = payload.rating + review.content = payload.content.strip() + review.version = tags.version or "unknown" + review.package_arch = tags.package_arch or "unknown" + review.client_arch = tags.client_arch or "unknown" + review.distro = tags.distro or "unknown" + review.origin = tags.origin + review.category = tags.category + app.latest_seen_version = review.version + db.commit() + db.refresh(review) + return to_public(review) + + +@router.get("/apps/{app_key}/reviews", response_model=list[ReviewPublic]) +def list_reviews( + app_key: str, + version: str | None = None, + package_arch: str | None = None, + client_arch: str | None = None, + distro: str | None = None, + origin: str | None = None, + category: str | None = None, + rating: int | None = Query(default=None, ge=1, le=5), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + db: Session = Depends(get_db), +) -> list[ReviewPublic]: + app = db.scalar(select(StoreApp).where(StoreApp.app_key == app_key)) + if app is None: + return [] + stmt = select(Review).where(Review.app_id == app.id).order_by(Review.updated_at.desc()) + filters = { + Review.version: version, + Review.package_arch: package_arch, + Review.client_arch: client_arch, + Review.distro: distro, + Review.origin: origin, + Review.category: category, + Review.rating: rating, + } + for column, value in filters.items(): + if value is not None: + stmt = stmt.where(column == value) + reviews = db.scalars(stmt.offset((page - 1) * page_size).limit(page_size)).all() + return [to_public(review) for review in reviews] + + +@router.get("/apps/{app_key}/rating-summary", response_model=RatingSummary) +def rating_summary(app_key: str, db: Session = Depends(get_db)) -> RatingSummary: + app = db.scalar(select(StoreApp).where(StoreApp.app_key == app_key)) + if app is None: + return RatingSummary(average_rating=0.0, review_count=0, star_counts={star: 0 for star in range(1, 6)}) + rows = db.execute(select(Review.rating, func.count(Review.id)).where(Review.app_id == app.id).group_by(Review.rating)).all() + star_counts = {star: 0 for star in range(1, 6)} + total = 0 + count = 0 + for rating, rating_count in rows: + star_counts[int(rating)] = int(rating_count) + total += int(rating) * int(rating_count) + count += int(rating_count) + average = round(total / count, 2) if count else 0.0 + return RatingSummary(average_rating=average, review_count=count, star_counts=star_counts) +``` + +- [ ] **Step 5: Include routes** + +Modify `app/api/router.py` to include: + +```python +from app.api.routes import auth, reviews + +api_router = APIRouter() +api_router.include_router(auth.router) +api_router.include_router(reviews.router) +``` + +- [ ] **Step 6: Run review tests and verify pass** + +Run: `pytest tests/test_reviews.py -v` + +Expected: PASS. + +- [ ] **Step 7: Commit reviews** + +Run: + +```bash +git add app tests/test_reviews.py +git commit -m "feat: add app reviews and ratings" +``` + +Expected: commit succeeds. + +## Task 6: Implement App-List Sync + +**Files:** +- Create: `app/schemas/app_list.py` +- Create: `app/api/routes/app_lists.py` +- Modify: `app/api/router.py` +- Test: `tests/test_app_lists.py` + +- [ ] **Step 1: Write failing app-list tests** + +Create `tests/test_app_lists.py` with: + +```python +from app.core.security import create_access_token +from app.models.user import User + + +def seed_user(db_session): + user = User(flarum_user_id="123", username="momen", display_name="Momen", avatar_url="") + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +def auth_headers(user: User) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(str(user.id))}"} + + +def test_put_and_get_default_app_list(client, db_session): + user = seed_user(db_session) + response = client.put( + "/me/app-list", + headers=auth_headers(user), + json={ + "client_arch": "amd64", + "distro": "deepin 25", + "items": [ + { + "pkgname": "spark-notes", + "origin": "spark", + "category": "office", + "version": "1.0.0", + "package_arch": "amd64", + "app_name": "Spark Notes", + "icon_url": "https://example.com/icon.png", + }, + { + "pkgname": "wps", + "origin": "apm", + "category": "office", + "version": "2.0.0", + "package_arch": "amd64", + "app_name": "WPS", + "icon_url": "", + }, + ], + }, + ) + + assert response.status_code == 200 + assert len(response.json()["items"]) == 2 + + fetched = client.get("/me/app-list", headers=auth_headers(user)) + + assert fetched.status_code == 200 + assert fetched.json()["client_arch"] == "amd64" + assert [item["pkgname"] for item in fetched.json()["items"]] == ["spark-notes", "wps"] + + +def test_put_default_app_list_replaces_previous_items(client, db_session): + user = seed_user(db_session) + headers = auth_headers(user) + first_payload = { + "client_arch": "amd64", + "distro": "deepin 25", + "items": [ + {"pkgname": "one", "origin": "spark", "category": "tools", "version": "1", "package_arch": "amd64", "app_name": "One", "icon_url": ""}, + {"pkgname": "two", "origin": "apm", "category": "tools", "version": "1", "package_arch": "amd64", "app_name": "Two", "icon_url": ""}, + ], + } + second_payload = { + "client_arch": "amd64", + "distro": "deepin 25", + "items": [ + {"pkgname": "three", "origin": "spark", "category": "tools", "version": "1", "package_arch": "amd64", "app_name": "Three", "icon_url": ""}, + ], + } + + assert client.put("/me/app-list", headers=headers, json=first_payload).status_code == 200 + assert client.put("/me/app-list", headers=headers, json=second_payload).status_code == 200 + fetched = client.get("/me/app-list", headers=headers) + + assert [item["pkgname"] for item in fetched.json()["items"]] == ["three"] +``` + +- [ ] **Step 2: Run app-list tests and verify failure** + +Run: `pytest tests/test_app_lists.py -v` + +Expected: FAIL with `404 Not Found` for `/me/app-list`. + +- [ ] **Step 3: Add app-list schemas** + +Create `app/schemas/app_list.py` with: + +```python +from datetime import datetime + +from pydantic import BaseModel, Field + + +class AppListItemIn(BaseModel): + pkgname: str = Field(min_length=1, max_length=255) + origin: str = Field(min_length=1, max_length=16) + category: str = Field(min_length=1, max_length=128) + version: str = Field(default="", max_length=255) + package_arch: str = Field(default="unknown", max_length=64) + app_name: str = Field(default="", max_length=255) + icon_url: str = Field(default="", max_length=1024) + + +class AppListPut(BaseModel): + client_arch: str = Field(default="unknown", max_length=64) + distro: str = Field(default="unknown", max_length=255) + items: list[AppListItemIn] + + +class AppListItemOut(AppListItemIn): + id: int + + model_config = {"from_attributes": True} + + +class AppListOut(BaseModel): + snapshot_name: str + client_arch: str + distro: str + updated_at: datetime + items: list[AppListItemOut] +``` + +- [ ] **Step 4: Add app-list routes** + +Create `app/api/routes/app_lists.py` with: + +```python +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.app_list import UserAppList, UserAppListItem +from app.models.user import User +from app.schemas.app_list import AppListOut, AppListPut + +router = APIRouter(tags=["app-lists"]) + + +def to_out(app_list: UserAppList) -> AppListOut: + return AppListOut( + snapshot_name=app_list.snapshot_name, + client_arch=app_list.client_arch, + distro=app_list.distro, + updated_at=app_list.updated_at, + items=list(app_list.items), + ) + + +@router.get("/me/app-list", response_model=AppListOut | None) +def get_app_list( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> AppListOut | None: + app_list = db.scalar( + select(UserAppList).where( + UserAppList.user_id == user.id, + UserAppList.snapshot_name == "default", + ) + ) + return to_out(app_list) if app_list else None + + +@router.put("/me/app-list", response_model=AppListOut) +def put_app_list( + payload: AppListPut, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> AppListOut: + app_list = db.scalar( + select(UserAppList).where( + UserAppList.user_id == user.id, + UserAppList.snapshot_name == "default", + ) + ) + if app_list is None: + app_list = UserAppList(user_id=user.id, snapshot_name="default") + db.add(app_list) + db.flush() + + app_list.client_arch = payload.client_arch + app_list.distro = payload.distro + app_list.items.clear() + for item in payload.items: + app_list.items.append(UserAppListItem(**item.model_dump())) + db.commit() + db.refresh(app_list) + return to_out(app_list) +``` + +- [ ] **Step 5: Include routes** + +Modify `app/api/router.py` to include: + +```python +from app.api.routes import app_lists, auth, reviews + +api_router = APIRouter() +api_router.include_router(auth.router) +api_router.include_router(reviews.router) +api_router.include_router(app_lists.router) +``` + +- [ ] **Step 6: Run app-list tests and verify pass** + +Run: `pytest tests/test_app_lists.py -v` + +Expected: PASS. + +- [ ] **Step 7: Commit app-list sync** + +Run: + +```bash +git add app tests/test_app_lists.py +git commit -m "feat: add app list sync api" +``` + +Expected: commit succeeds. + +## Task 7: Final Backend Verification And Push + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Document run commands** + +Add this section to `README.md`: + +```markdown +## Run + +```bash +uvicorn app.main:app --reload +``` + +## Verify + +```bash +pytest +alembic upgrade head +``` + +## API Groups + +- `POST /auth/flarum` +- `GET /me` +- `GET /apps/{app_key}/rating-summary` +- `GET /apps/{app_key}/reviews` +- `POST /apps/{app_key}/reviews` +- `GET /me/app-list` +- `PUT /me/app-list` +``` + +- [ ] **Step 2: Run full backend tests** + +Run: `pytest -v` + +Expected: all tests PASS. + +- [ ] **Step 3: Verify migration command** + +Run: `alembic upgrade head` + +Expected: command exits 0. + +- [ ] **Step 4: Commit docs** + +Run: + +```bash +git add README.md +git commit -m "docs: add backend run instructions" +``` + +Expected: commit succeeds if README changed. + +- [ ] **Step 5: Push backend repository** + +Run: `git push -u origin master` + +Expected: push succeeds if credentials and remote permissions are available. If authentication fails, report the exact Git error and leave the local repository intact. + +## Self-Review Checklist + +Spec coverage: + +- Flarum token validation: Task 4. +- Backend JWT: Task 4. +- Review/rating storage and filtering foundation: Task 5. +- Default app-list sync: Task 6. +- MySQL schema and migrations: Task 3. +- New Git repository and remote: Task 1. + +Placeholder scan: + +- The plan has no deferred implementation sections and no placeholder tasks. + +Type consistency: + +- User id is local integer `users.id` inside JWT `sub`. +- Flarum user id remains string `flarum_user_id`. +- App identity uses string `app_key` in all review routes. diff --git a/docs/superpowers/plans/2026-05-18-spark-client-account-collections.md b/docs/superpowers/plans/2026-05-18-spark-client-account-collections.md new file mode 100644 index 00000000..57920eeb --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-spark-client-account-collections.md @@ -0,0 +1,3109 @@ +# Spark Client Account Collections Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend the Electron/Vue Spark Store client with forum login, sidebar account entry, account management, main-content detail pages, reviews, favorites, downloaded history, and cloud installed-app sync while preserving anonymous browse/install/remove/update flows. + +**Architecture:** Keep backend communication in small TypeScript modules and keep Vue state in focused global modules. Replace the app-detail overlay with a routed state inside `App.vue` content area, and add account/favorites/user-management components without changing Electron package-management IPC contracts. + +**Tech Stack:** Electron 40, Vue 3 Composition API, TypeScript strict mode, Axios, Vitest, Testing Library Vue, existing IPC facade, existing install queue, FastAPI backend API. + +--- + +## File Structure + +Client repository: `/home/spark/Desktop/shenmo-spark-store/spark-store`. + +Modify: +- `.gitignore` - already ignores `.superpowers/`; leave as-is unless missing. +- `src/global/storeConfig.ts` - add backend URL and forum URLs. +- `src/global/typedefinition.ts` - add account, review, favorite, downloaded, sync, and availability types. +- `src/vite-env.d.ts` - add backend env and `window.ipcRenderer.invoke` usage remains unchanged. +- `src/__tests__/setup.ts` - keep `window.apm_store.arch` as bare architecture `amd64` and make IPC mocks writable. +- `src/components/AppSidebar.vue` - replace the title block with account entry and quick menu events. +- `src/App.vue` - coordinate auth modal, detail view state, account management view, favorites, reviews, downloaded records, and startup sync. +- `src/modules/processInstall.ts` - expose selected-app queue creation result for cloud downloaded-record writes without changing existing install IPC payloads. +- `src/components/InstalledAppsModal.vue` - add cloud sync actions and login gating. + +Create: +- `src/global/authState.ts` - auth session persistence and token header propagation. +- `src/global/accountSyncState.ts` - local installed-sync preference helpers. +- `src/modules/backendApi.ts` - backend client, DTO mapping, favorites, reviews, downloaded records, and app-list API helpers. +- `src/modules/flarumAuth.ts` - Flarum token exchange helper; forum password never leaves this request. +- `src/modules/appIdentity.ts` - favorite app key, review app key, package arch, and selected display app helpers. +- `src/modules/favoriteAvailability.ts` - client-side favorite resolution and batch-install planning. +- `src/modules/appListSync.ts` - installed-list filtering and cloud sync payload helpers. +- `src/components/LoginModal.vue` - forum login/register prompt. +- `src/components/LoginPromptModal.vue` - reusable account-only feature gate prompt. +- `src/components/AccountQuickMenu.vue` - quick account menu for logged-in users. +- `src/components/AppDetailPage.vue` - full content-area detail page replacing `AppDetailModal`. +- `src/components/ReviewsPanel.vue` - review list/composer with anonymous prompt. +- `src/components/FavoriteFolderSelector.vue` - favorite folder selector/add action. +- `src/components/UserManagementView.vue` - profile, links, downloaded history, sync preference, and favorites entry. +- `src/components/FavoriteFolderManager.vue` - folders/items/statuses/batch install/bulk delete. +- `src/components/AppListRestoreModal.vue` - cloud app-list restore selector. +- `src/__tests__/unit/accountTypes.test.ts` - type/config smoke test. +- `src/__tests__/unit/authState.test.ts` - auth persistence test. +- `src/__tests__/unit/LoginModal.test.ts` - login/register modal test. +- `src/__tests__/unit/AppSidebar.account.test.ts` - account entry and quick menu tests. +- `src/__tests__/unit/appIdentity.test.ts` - key/tag helper tests. +- `src/__tests__/unit/favoriteAvailability.test.ts` - status and batch source selection tests. +- `src/__tests__/unit/appListSync.test.ts` - installed sync filtering tests. +- `src/__tests__/unit/AppDetailPage.test.ts` - detail page/back/favorite prompt test. +- `src/__tests__/unit/FavoriteFolderManager.test.ts` - folder manager status/action test. +- `src/__tests__/unit/UserManagementView.test.ts` - account management rendering test. +- `src/__tests__/unit/AppListRestoreModal.test.ts` - restore selector test. + +## Task 1: Add Account Config, Types, And API Client + +**Files:** +- Modify: `src/global/storeConfig.ts` +- Modify: `src/global/typedefinition.ts` +- Modify: `src/vite-env.d.ts` +- Modify: `src/__tests__/setup.ts` +- Create: `src/modules/backendApi.ts` +- Test: `src/__tests__/unit/accountTypes.test.ts` + +- [ ] **Step 1: Write failing account type smoke test** + +Create `src/__tests__/unit/accountTypes.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { + FLARUM_BASE_URL, + FLARUM_REGISTER_URL, + SPARK_BACKEND_BASE_URL, +} from "@/global/storeConfig"; +import type { + DownloadedAppRecord, + FavoriteFolder, + FavoriteItem, + ReviewTags, + SparkUser, + SyncedAppListItem, +} from "@/global/typedefinition"; + +describe("account shared types", () => { + it("exports backend/forum config and account shapes", () => { + const user: SparkUser = { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + forumLevel: "管理员", + forumGroups: ["管理员"], + }; + const folder: FavoriteFolder = { + id: 1, + name: "默认收藏夹", + itemCount: 1, + createdAt: "2026-05-18T00:00:00Z", + updatedAt: "2026-05-18T00:00:00Z", + }; + const favorite: FavoriteItem = { + id: 2, + appKey: "app:office:wps", + pkgname: "wps", + name: "WPS", + category: "office", + iconUrl: "https://example.invalid/wps.png", + createdAt: "2026-05-18T00:00:00Z", + }; + const download: DownloadedAppRecord = { + id: 3, + appKey: "app:office:wps", + pkgname: "wps", + name: "WPS", + category: "office", + selectedOrigin: "apm", + version: "1.0.0", + packageArch: "amd64", + downloadedAt: "2026-05-18T00:00:00Z", + }; + const syncItem: SyncedAppListItem = { + pkgname: "wps", + origin: "apm", + category: "office", + version: "1.0.0", + packageArch: "amd64", + appName: "WPS", + iconUrl: "https://example.invalid/wps.png", + }; + const tags: ReviewTags = { + origin: "apm", + category: "office", + pkgname: "wps", + version: "1.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", + }; + + expect(typeof SPARK_BACKEND_BASE_URL).toBe("string"); + expect(FLARUM_BASE_URL).toContain("bbs.spark-app.store"); + expect(FLARUM_REGISTER_URL).toContain("register"); + expect(user.forumGroups).toEqual(["管理员"]); + expect(folder.itemCount).toBe(1); + expect(favorite.appKey).toBe("app:office:wps"); + expect(download.selectedOrigin).toBe("apm"); + expect(syncItem.origin).toBe("apm"); + expect(tags.packageArch).toBe("amd64"); + }); +}); +``` + +- [ ] **Step 2: Run the smoke test and verify failure** + +Run: `npm run test -- src/__tests__/unit/accountTypes.test.ts` + +Expected: FAIL because new config constants and account types do not exist. + +- [ ] **Step 3: Add backend and forum config** + +Append to `src/global/storeConfig.ts` after `APM_STORE_STATS_BASE_URL`: + +```typescript +export const SPARK_BACKEND_BASE_URL: string = + import.meta.env.VITE_SPARK_BACKEND_BASE_URL || ""; + +export const FLARUM_BASE_URL = "https://bbs.spark-app.store"; +export const FLARUM_REGISTER_URL = `${FLARUM_BASE_URL}/register`; +export const FLARUM_PROFILE_URL = `${FLARUM_BASE_URL}/u`; +export const FLARUM_SETTINGS_URL = `${FLARUM_BASE_URL}/settings`; +``` + +- [ ] **Step 4: Add account types** + +Append to `src/global/typedefinition.ts`: + +```typescript +export interface SparkUser { + id: number; + flarumUserId: string; + username: string; + displayName: string; + avatarUrl: string; + forumLevel: string; + forumGroups: string[]; +} + +export interface AuthSession { + accessToken: string; + tokenType: "bearer"; + user: SparkUser; +} + +export interface FlarumLoginPayload { + identification: string; + password: string; +} + +export interface ReviewTags { + origin: "spark" | "apm"; + category: string; + pkgname: string; + version: string; + packageArch: string; + clientArch: string; + distro: string; +} + +export interface RatingSummary { + averageRating: number; + reviewCount: number; + starCounts: Record; +} + +export interface AppReview { + id: number; + rating: number; + content: string; + version: string; + packageArch: string; + clientArch: string; + distro: string; + origin: "spark" | "apm"; + category: string; + createdAt: string; + updatedAt: string; + userDisplayName: string; + userAvatarUrl: string; +} + +export interface FavoriteFolder { + id: number; + name: string; + itemCount: number; + createdAt: string; + updatedAt: string; +} + +export interface FavoriteItem { + id: number; + appKey: string; + pkgname: string; + name: string; + category: string; + iconUrl: string; + createdAt: string; +} + +export type FavoriteAvailabilityStatus = + | "installable" + | "installed" + | "platform-unavailable" + | "arch-unavailable" + | "downlisted"; + +export interface ResolvedFavoriteItem { + item: FavoriteItem; + status: FavoriteAvailabilityStatus; + reason: string; + selectedApp: App | null; +} + +export interface DownloadedAppRecord { + id: number; + appKey: string; + pkgname: string; + name: string; + category: string; + selectedOrigin: "spark" | "apm"; + version: string; + packageArch: string; + downloadedAt: string; +} + +export interface DownloadedAppList { + items: DownloadedAppRecord[]; + total: number; + page: number; + pageSize: number; +} + +export interface SyncedAppListItem { + id?: number; + pkgname: string; + origin: "spark" | "apm"; + category: string; + version: string; + packageArch: string; + appName: string; + iconUrl: string; +} + +export interface SyncedAppList { + snapshotName: string; + clientArch: string; + distro: string; + updatedAt: string; + items: SyncedAppListItem[]; +} + +export interface SystemInfo { + distro: string; +} +``` + +- [ ] **Step 5: Add environment declaration** + +Add `ImportMetaEnv` inside the existing `declare global` block in `src/vite-env.d.ts`: + +```typescript + interface ImportMetaEnv { + readonly VITE_SPARK_BACKEND_BASE_URL?: string; + } +``` + +- [ ] **Step 6: Normalize test IPC mocks** + +Modify `src/__tests__/setup.ts` so both exposed globals are writable and `window.apm_store.arch` is bare `amd64`: + +```typescript +Object.defineProperty(window, "ipcRenderer", { + value: { + send: vi.fn(), + on: vi.fn(), + off: vi.fn(), + invoke: vi.fn(), + removeListener: vi.fn(), + }, + writable: true, +}); + +Object.defineProperty(window, "apm_store", { + value: { + arch: "amd64", + }, + writable: true, +}); +``` + +- [ ] **Step 7: Add backend API helper** + +Create `src/modules/backendApi.ts` with: + +```typescript +import axios from "axios"; + +import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig"; +import type { + AppReview, + AuthSession, + DownloadedAppList, + DownloadedAppRecord, + FavoriteFolder, + FavoriteItem, + RatingSummary, + ReviewTags, + SyncedAppList, + SyncedAppListItem, +} from "@/global/typedefinition"; + +const backend = axios.create({ + baseURL: SPARK_BACKEND_BASE_URL, + timeout: 10000, +}); + +const parseForumGroups = (raw: unknown): string[] => { + if (Array.isArray(raw)) return raw.filter((item): item is string => typeof item === "string"); + if (typeof raw !== "string" || raw.length === 0) return []; + try { + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []; + } catch { + return []; + } +}; + +const toUser = (raw: Record): AuthSession["user"] => ({ + id: Number(raw.id), + flarumUserId: String(raw.flarum_user_id || ""), + username: String(raw.username || ""), + displayName: String(raw.display_name || raw.username || ""), + avatarUrl: String(raw.avatar_url || ""), + forumLevel: String(raw.forum_level || "论坛用户"), + forumGroups: parseForumGroups(raw.forum_groups), +}); + +const toReview = (raw: Record): AppReview => ({ + id: Number(raw.id), + rating: Number(raw.rating), + content: String(raw.content || ""), + version: String(raw.version || "unknown"), + packageArch: String(raw.package_arch || "unknown"), + clientArch: String(raw.client_arch || "unknown"), + distro: String(raw.distro || "unknown"), + origin: raw.origin === "spark" ? "spark" : "apm", + category: String(raw.category || ""), + createdAt: String(raw.created_at || ""), + updatedAt: String(raw.updated_at || ""), + userDisplayName: String(raw.user_display_name || ""), + userAvatarUrl: String(raw.user_avatar_url || ""), +}); + +const toFavoriteFolder = (raw: Record): FavoriteFolder => ({ + id: Number(raw.id), + name: String(raw.name || ""), + itemCount: Number(raw.item_count || 0), + createdAt: String(raw.created_at || ""), + updatedAt: String(raw.updated_at || ""), +}); + +const toFavoriteItem = (raw: Record): FavoriteItem => ({ + id: Number(raw.id), + appKey: String(raw.app_key || ""), + pkgname: String(raw.pkgname || ""), + name: String(raw.name || ""), + category: String(raw.category || ""), + iconUrl: String(raw.icon_url || ""), + createdAt: String(raw.created_at || ""), +}); + +const toDownloadedApp = (raw: Record): DownloadedAppRecord => ({ + id: Number(raw.id), + appKey: String(raw.app_key || ""), + pkgname: String(raw.pkgname || ""), + name: String(raw.name || ""), + category: String(raw.category || ""), + selectedOrigin: raw.selected_origin === "spark" ? "spark" : "apm", + version: String(raw.version || ""), + packageArch: String(raw.package_arch || "unknown"), + downloadedAt: String(raw.downloaded_at || ""), +}); + +export const setBackendToken = (token: string | null) => { + if (token) backend.defaults.headers.common.Authorization = `Bearer ${token}`; + else delete backend.defaults.headers.common.Authorization; +}; + +export const exchangeFlarumToken = async (payload: { + flarumUserId: string; + flarumToken: string; +}): Promise => { + const response = await backend.post("/auth/flarum", { + flarum_user_id: payload.flarumUserId, + flarum_token: payload.flarumToken, + }); + return { + accessToken: String(response.data.access_token), + tokenType: "bearer", + user: toUser(response.data.user), + }; +}; + +export const fetchMe = async (): Promise => { + const response = await backend.get("/me"); + return toUser(response.data); +}; + +export const fetchRatingSummary = async (appKey: string): Promise => { + const response = await backend.get(`/apps/${encodeURIComponent(appKey)}/rating-summary`); + return { + averageRating: Number(response.data.average_rating || 0), + reviewCount: Number(response.data.review_count || 0), + starCounts: response.data.star_counts || {}, + }; +}; + +export const fetchReviews = async (appKey: string): Promise => { + const response = await backend.get(`/apps/${encodeURIComponent(appKey)}/reviews`); + return (response.data || []).map((item: Record) => toReview(item)); +}; + +export const submitReview = async ( + appKey: string, + payload: { rating: number; content: string; tags: ReviewTags }, +): Promise => { + const response = await backend.post(`/apps/${encodeURIComponent(appKey)}/reviews`, { + rating: payload.rating, + content: payload.content, + tags: { + origin: payload.tags.origin, + category: payload.tags.category, + pkgname: payload.tags.pkgname, + version: payload.tags.version, + package_arch: payload.tags.packageArch, + client_arch: payload.tags.clientArch, + distro: payload.tags.distro, + }, + }); + return toReview(response.data); +}; + +export const listFavoriteFolders = async (): Promise => { + const response = await backend.get("/me/favorite-folders"); + return (response.data || []).map((item: Record) => toFavoriteFolder(item)); +}; + +export const createFavoriteFolder = async (name: string): Promise => { + const response = await backend.post("/me/favorite-folders", { name }); + return toFavoriteFolder(response.data); +}; + +export const renameFavoriteFolder = async (folderId: number, name: string): Promise => { + const response = await backend.patch(`/me/favorite-folders/${folderId}`, { name }); + return toFavoriteFolder(response.data); +}; + +export const deleteFavoriteFolder = async (folderId: number): Promise => { + await backend.delete(`/me/favorite-folders/${folderId}`); +}; + +export const listFavoriteItems = async (folderId: number): Promise => { + const response = await backend.get(`/me/favorite-folders/${folderId}/items`); + return (response.data || []).map((item: Record) => toFavoriteItem(item)); +}; + +export const addFavoriteItem = async ( + folderId: number | "default", + item: Omit, +): Promise => { + const response = await backend.post(`/me/favorite-folders/${folderId}/items`, { + app_key: item.appKey, + pkgname: item.pkgname, + name: item.name, + category: item.category, + icon_url: item.iconUrl, + }); + return toFavoriteItem(response.data); +}; + +export const deleteFavoriteItem = async (folderId: number, itemId: number): Promise => { + await backend.delete(`/me/favorite-folders/${folderId}/items/${itemId}`); +}; + +export const bulkDeleteFavoriteItems = async (folderId: number, itemIds: number[]): Promise => { + const response = await backend.post(`/me/favorite-folders/${folderId}/items/bulk-delete`, { item_ids: itemIds }); + return Number(response.data.deleted_count || 0); +}; + +export const listDownloadedApps = async (page = 1, pageSize = 20): Promise => { + const response = await backend.get("/me/downloaded-apps", { params: { page, page_size: pageSize } }); + return { + items: (response.data.items || []).map((item: Record) => toDownloadedApp(item)), + total: Number(response.data.total || 0), + page: Number(response.data.page || page), + pageSize: Number(response.data.page_size || pageSize), + }; +}; + +export const recordDownloadedApp = async (item: Omit): Promise => { + const response = await backend.post("/me/downloaded-apps", { + app_key: item.appKey, + pkgname: item.pkgname, + name: item.name, + category: item.category, + selected_origin: item.selectedOrigin, + version: item.version, + package_arch: item.packageArch, + }); + return toDownloadedApp(response.data); +}; + +export const fetchSyncedAppList = async (): Promise => { + const response = await backend.get("/me/app-list"); + if (!response.data) return null; + return { + snapshotName: String(response.data.snapshot_name || "默认列表"), + clientArch: String(response.data.client_arch || "unknown"), + distro: String(response.data.distro || "unknown"), + updatedAt: String(response.data.updated_at || ""), + items: (response.data.items || []).map((item: Record) => ({ + id: Number(item.id), + pkgname: String(item.pkgname || ""), + origin: item.origin === "spark" ? "spark" : "apm", + category: String(item.category || ""), + version: String(item.version || ""), + packageArch: String(item.package_arch || "unknown"), + appName: String(item.app_name || ""), + iconUrl: String(item.icon_url || ""), + })), + }; +}; + +export const uploadSyncedAppList = async (payload: { + clientArch: string; + distro: string; + items: SyncedAppListItem[]; +}): Promise => { + const response = await backend.put("/me/app-list", { + client_arch: payload.clientArch, + distro: payload.distro, + items: payload.items.map((item) => ({ + pkgname: item.pkgname, + origin: item.origin, + category: item.category, + version: item.version, + package_arch: item.packageArch, + app_name: item.appName, + icon_url: item.iconUrl, + })), + }); + return { + snapshotName: String(response.data.snapshot_name || "默认列表"), + clientArch: String(response.data.client_arch || payload.clientArch), + distro: String(response.data.distro || payload.distro), + updatedAt: String(response.data.updated_at || ""), + items: payload.items, + }; +}; +``` + +- [ ] **Step 8: Run type smoke test and verify pass** + +Run: `npm run test -- src/__tests__/unit/accountTypes.test.ts` + +Expected: PASS. + +- [ ] **Step 9: Commit shared client account API foundation** + +Run: + +```bash +git add src/global/storeConfig.ts src/global/typedefinition.ts src/vite-env.d.ts src/__tests__/setup.ts src/modules/backendApi.ts src/__tests__/unit/accountTypes.test.ts +git commit -m "feat(account): add client account api foundation" +``` + +Expected: commit succeeds. + +## Task 2: Add Auth State, Flarum Login, And Sidebar Account Entry + +**Files:** +- Create: `src/global/authState.ts` +- Create: `src/modules/flarumAuth.ts` +- Create: `src/components/LoginModal.vue` +- Create: `src/components/LoginPromptModal.vue` +- Create: `src/components/AccountQuickMenu.vue` +- Modify: `src/components/AppSidebar.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/authState.test.ts` +- Test: `src/__tests__/unit/LoginModal.test.ts` +- Test: `src/__tests__/unit/AppSidebar.account.test.ts` + +- [ ] **Step 1: Write failing auth state test** + +Create `src/__tests__/unit/authState.test.ts` with: + +```typescript +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("authState", () => { + beforeEach(() => { + vi.resetModules(); + localStorage.clear(); + }); + + it("persists and clears a backend session", async () => { + const { authSession, currentUser, isLoggedIn, setAuthSession, logout } = await import("@/global/authState"); + + setAuthSession({ + accessToken: "jwt", + tokenType: "bearer", + user: { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + forumLevel: "管理员", + forumGroups: ["管理员"], + }, + }); + + expect(authSession.value?.accessToken).toBe("jwt"); + expect(currentUser.value?.displayName).toBe("Momen"); + expect(isLoggedIn.value).toBe(true); + expect(JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken).toBe("jwt"); + + logout(); + + expect(authSession.value).toBeNull(); + expect(isLoggedIn.value).toBe(false); + expect(localStorage.getItem("spark-store-auth")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Write failing login modal test** + +Create `src/__tests__/unit/LoginModal.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import LoginModal from "@/components/LoginModal.vue"; + +describe("LoginModal", () => { + it("emits login credentials and register request", async () => { + const rendered = render(LoginModal, { + props: { show: true, loading: false, error: "" }, + }); + + await fireEvent.update(screen.getByLabelText("论坛账号"), "momen"); + await fireEvent.update(screen.getByLabelText("论坛密码"), "secret"); + await fireEvent.click(screen.getByRole("button", { name: "登录" })); + await fireEvent.click(screen.getByRole("button", { name: "注册账号" })); + + expect(rendered.emitted("login")?.[0]?.[0]).toEqual({ identification: "momen", password: "secret" }); + expect(rendered.emitted("register")).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 3: Write failing sidebar account entry test** + +Create `src/__tests__/unit/AppSidebar.account.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import AppSidebar from "@/components/AppSidebar.vue"; +import type { SparkUser } from "@/global/typedefinition"; + +const baseProps = { + activeTab: "all", + categoryCounts: { all: 0 }, + themeMode: "auto" as const, + storeFilter: "both" as const, + sparkAvailable: true, + apmAvailable: true, + sidebarEntries: [], + entryCounts: {}, +}; + +const user: SparkUser = { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + forumLevel: "管理员", + forumGroups: ["管理员"], +}; + +describe("AppSidebar account entry", () => { + it("prompts login when anonymous", async () => { + const rendered = render(AppSidebar, { props: { ...baseProps, currentUser: null } }); + + await fireEvent.click(screen.getByRole("button", { name: /登录 \/ 注册/ })); + + expect(rendered.emitted("request-login")).toHaveLength(1); + }); + + it("opens quick menu for logged-in users", async () => { + render(AppSidebar, { props: { ...baseProps, currentUser: user } }); + + await fireEvent.click(screen.getByRole("button", { name: /Momen/ })); + + expect(screen.getByText("用户管理")).toBeTruthy(); + expect(screen.getByText("我的收藏")).toBeTruthy(); + expect(screen.getByText("退出登录")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 4: Run auth/login/sidebar tests and verify failure** + +Run: + +```bash +npm run test -- src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/AppSidebar.account.test.ts +``` + +Expected: FAIL because modules/components/props do not exist. + +- [ ] **Step 5: Add auth state** + +Create `src/global/authState.ts` with: + +```typescript +import { computed, ref } from "vue"; + +import type { AuthSession } from "@/global/typedefinition"; +import { setBackendToken } from "@/modules/backendApi"; + +const STORAGE_KEY = "spark-store-auth"; + +const readStoredSession = (): AuthSession | null => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as AuthSession; + if (!parsed.accessToken || parsed.tokenType !== "bearer" || !parsed.user) return null; + return parsed; + } catch { + return null; + } +}; + +export const authSession = ref(readStoredSession()); +export const currentUser = computed(() => authSession.value?.user ?? null); +export const isLoggedIn = computed(() => Boolean(authSession.value?.accessToken)); + +setBackendToken(authSession.value?.accessToken ?? null); + +export const setAuthSession = (session: AuthSession) => { + authSession.value = session; + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); + setBackendToken(session.accessToken); +}; + +export const logout = () => { + authSession.value = null; + localStorage.removeItem(STORAGE_KEY); + setBackendToken(null); +}; +``` + +- [ ] **Step 6: Add Flarum login helper** + +Create `src/modules/flarumAuth.ts` with: + +```typescript +import axios from "axios"; + +import { FLARUM_BASE_URL } from "@/global/storeConfig"; +import type { FlarumLoginPayload } from "@/global/typedefinition"; + +export interface FlarumTokenResponse { + token: string; + userId: string; +} + +export const requestFlarumToken = async (payload: FlarumLoginPayload): Promise => { + const response = await axios.post(`${FLARUM_BASE_URL}/api/token`, { + identification: payload.identification, + password: payload.password, + }); + return { + token: String(response.data.token || ""), + userId: String(response.data.userId || ""), + }; +}; +``` + +- [ ] **Step 7: Create login modal** + +Create `src/components/LoginModal.vue` with: + +```vue + + + +``` + +- [ ] **Step 8: Create reusable login prompt modal** + +Create `src/components/LoginPromptModal.vue` with: + +```vue + + + +``` + +- [ ] **Step 9: Create account quick menu** + +Create `src/components/AccountQuickMenu.vue` with: + +```vue + + + + + +``` + +- [ ] **Step 10: Update sidebar account entry** + +Modify `src/components/AppSidebar.vue`: + +1. Import `ref`, `AccountQuickMenu`, and `SparkUser`: + +```typescript +import { computed, ref } from "vue"; +import AccountQuickMenu from "./AccountQuickMenu.vue"; +import type { SidebarEntry, SparkUser } from "../global/typedefinition"; +``` + +2. Add prop: + +```typescript + currentUser: SparkUser | null; +``` + +3. Add emits: + +```typescript + (e: "request-login"): void; + (e: "open-user-management"): void; + (e: "open-favorites"): void; + (e: "open-forum"): void; + (e: "edit-profile"): void; + (e: "logout"): void; +``` + +4. Add state and handler: + +```typescript +const showAccountMenu = ref(false); + +const handleAccountClick = () => { + if (!props.currentUser) { + emit("request-login"); + return; + } + showAccountMenu.value = !showAccountMenu.value; +}; +``` + +5. Replace the current logo/title `
...
` at the top with: + +```vue +
+ + +
+``` + +- [ ] **Step 11: Wire login shell in App.vue** + +Modify `src/App.vue`: + +1. Import new state/components/helpers: + +```typescript +import LoginModal from "./components/LoginModal.vue"; +import LoginPromptModal from "./components/LoginPromptModal.vue"; +import { currentUser, isLoggedIn, logout, setAuthSession } from "./global/authState"; +import { FLARUM_BASE_URL, FLARUM_REGISTER_URL, FLARUM_SETTINGS_URL } from "./global/storeConfig"; +import { exchangeFlarumToken } from "./modules/backendApi"; +import { requestFlarumToken } from "./modules/flarumAuth"; +import type { FlarumLoginPayload } from "./global/typedefinition"; +``` + +2. Add state: + +```typescript +const showLoginModal = ref(false); +const loginLoading = ref(false); +const loginError = ref(""); +const showLoginPrompt = ref(false); +const loginPromptMessage = ref("该功能需要登录星火账号后使用。"); +``` + +3. Pass sidebar props/events: + +```vue +:current-user="currentUser" +@request-login="showLoginModal = true" +@open-user-management="openUserManagement" +@open-favorites="openFavoriteManagement" +@open-forum="openExternalUrl(FLARUM_BASE_URL)" +@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)" +@logout="logout" +``` + +4. Mount modals before `AboutModal`: + +```vue + + +``` + +5. Add handlers: + +```typescript +const openExternalUrl = (url: string) => { + window.open(url, "_blank"); +}; + +const requireLogin = (message: string) => { + if (isLoggedIn.value) return true; + loginPromptMessage.value = message; + showLoginPrompt.value = true; + return false; +}; + +const openLoginFromPrompt = () => { + showLoginPrompt.value = false; + showLoginModal.value = true; +}; + +const handleFlarumLogin = async (payload: FlarumLoginPayload) => { + loginLoading.value = true; + loginError.value = ""; + try { + const flarum = await requestFlarumToken(payload); + const session = await exchangeFlarumToken({ flarumUserId: flarum.userId, flarumToken: flarum.token }); + setAuthSession(session); + showLoginModal.value = false; + } catch (error) { + loginError.value = error instanceof Error ? error.message : "登录失败"; + } finally { + loginLoading.value = false; + } +}; + +const openUserManagement = async () => { + if (!requireLogin("用户管理需要登录星火账号。")) return; + currentView.value = "account"; + activeTab.value = "account"; +}; + +const openFavoriteManagement = async () => { + if (!requireLogin("我的收藏需要登录星火账号。")) return; + currentView.value = "favorites"; + activeTab.value = "favorites"; +}; +``` + +- [ ] **Step 12: Run auth/sidebar tests** + +Run: + +```bash +npm run test -- src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/AppSidebar.test.ts +``` + +Expected: PASS. If existing `AppSidebar.test.ts` fails, add `currentUser: null` to its `renderSidebar` default props. + +- [ ] **Step 13: Commit auth and sidebar account entry** + +Run: + +```bash +git add src/global/authState.ts src/modules/flarumAuth.ts src/components/LoginModal.vue src/components/LoginPromptModal.vue src/components/AccountQuickMenu.vue src/components/AppSidebar.vue src/App.vue src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/AppSidebar.test.ts +git commit -m "feat(account): add forum login and sidebar account entry" +``` + +Expected: commit succeeds. + +## Task 3: Add App Identity Helpers And Main-Content Detail Page + +**Files:** +- Create: `src/modules/appIdentity.ts` +- Create: `src/components/AppDetailPage.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/appIdentity.test.ts` +- Test: `src/__tests__/unit/AppDetailPage.test.ts` + +- [ ] **Step 1: Write failing app identity tests** + +Create `src/__tests__/unit/appIdentity.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { buildFavoriteAppKey, buildReviewAppKey, buildReviewTags, getDisplayApp, parsePackageArch } from "@/modules/appIdentity"; +import type { App } from "@/global/typedefinition"; + +const app: App = { + name: "WPS", + pkgname: "wps", + version: "1.0.0", + filename: "wps_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: "", + tags: "", + img_urls: [], + icons: "", + category: "office", + origin: "apm", + currentStatus: "not-installed", +}; + +describe("appIdentity", () => { + it("builds favorite and review keys", () => { + expect(buildFavoriteAppKey(app)).toBe("app:office:wps"); + expect(buildReviewAppKey(app, "amd64")).toBe("apm:amd64-apm:office:wps"); + }); + + it("parses package arch and review tags", () => { + expect(parsePackageArch(app.filename)).toBe("amd64"); + expect(buildReviewTags(app, { clientArch: "amd64", distro: "deepin 25" })).toMatchObject({ + origin: "apm", + category: "office", + pkgname: "wps", + packageArch: "amd64", + }); + }); + + it("returns selected display app from merged apps", () => { + const merged: App = { ...app, isMerged: true, viewingOrigin: "spark", sparkApp: { ...app, origin: "spark" }, apmApp: app }; + expect(getDisplayApp(merged)?.origin).toBe("spark"); + }); +}); +``` + +- [ ] **Step 2: Write failing detail page test** + +Create `src/__tests__/unit/AppDetailPage.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import AppDetailPage from "@/components/AppDetailPage.vue"; +import type { App } from "@/global/typedefinition"; + +const app: App = { + name: "WPS", + pkgname: "wps", + version: "1.0.0", + filename: "wps_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "110M", + more: "Office suite", + tags: "office", + img_urls: [], + icons: "", + category: "office", + origin: "apm", + currentStatus: "not-installed", +}; + +describe("AppDetailPage", () => { + it("renders as page, emits back, and gates favorite for anonymous users", async () => { + const rendered = render(AppDetailPage, { + props: { + app, + screenshots: [], + sparkInstalled: false, + apmInstalled: false, + loggedIn: false, + reviewAppKey: "apm:amd64-apm:office:wps", + reviewTags: null, + }, + }); + + expect(screen.getByText("Office suite")).toBeTruthy(); + await fireEvent.click(screen.getByRole("button", { name: "返回" })); + await fireEvent.click(screen.getByRole("button", { name: "收藏" })); + + expect(rendered.emitted("back")).toHaveLength(1); + expect(rendered.emitted("request-login")?.[0]?.[0]).toBe("收藏应用需要登录星火账号。"); + }); +}); +``` + +- [ ] **Step 3: Run identity/detail tests and verify failure** + +Run: `npm run test -- src/__tests__/unit/appIdentity.test.ts src/__tests__/unit/AppDetailPage.test.ts` + +Expected: FAIL because helper and detail page do not exist. + +- [ ] **Step 4: Add app identity helpers** + +Create `src/modules/appIdentity.ts` with: + +```typescript +import type { App, ReviewTags } from "@/global/typedefinition"; + +export const parsePackageArch = (filename: string | undefined): string => { + if (!filename) return "unknown"; + const match = filename.match(/_([^_]+)\.(?:deb|rpm|appimage|tar\.gz)$/i); + return match?.[1] || "unknown"; +}; + +export const buildStoreArch = (origin: "spark" | "apm", clientArch: string): string => { + return origin === "spark" ? `${clientArch}-store` : `${clientArch}-apm`; +}; + +export const buildFavoriteAppKey = (app: Pick): string => { + return `app:${app.category || "unknown"}:${app.pkgname}`; +}; + +export const buildReviewAppKey = (app: App, clientArch: string): string => { + return `${app.origin}:${buildStoreArch(app.origin, clientArch)}:${app.category || "unknown"}:${app.pkgname}`; +}; + +export const getDisplayApp = (app: App | null): App | null => { + if (!app) return null; + if (!app.isMerged) return app; + if (app.viewingOrigin === "spark") return app.sparkApp || app; + if (app.viewingOrigin === "apm") return app.apmApp || app; + return app.sparkApp || app.apmApp || app; +}; + +export const buildReviewTags = ( + app: App, + system: { clientArch: string; distro: string }, +): ReviewTags => ({ + origin: app.origin, + category: app.category || "unknown", + pkgname: app.pkgname, + version: app.version || "unknown", + packageArch: app.arch || parsePackageArch(app.filename), + clientArch: system.clientArch || "unknown", + distro: system.distro || "unknown", +}); +``` + +- [ ] **Step 5: Create main-content detail page** + +Create `src/components/AppDetailPage.vue` with this page-level implementation: + +```vue + + + +``` + +Do not mount reviews in this task; Task 5 adds `ReviewsPanel`. + +- [ ] **Step 6: Replace modal state with detail page state in App.vue** + +Modify `src/App.vue`: + +1. Import `AppDetailPage` instead of `AppDetailModal`. +2. Replace `const showModal = ref(false);` with: + +```typescript +const currentView = ref<"home" | "list" | "detail" | "account" | "favorites">("home"); +const detailPreviousView = ref<"home" | "list">("home"); +``` + +3. Replace the content template branch at `src/App.vue:57-77` with: + +```vue +
+ + + +
+``` + +4. In `selectTab`, after setting `activeTab`, add: + +```typescript +currentView.value = tab === "home" ? "home" : "list"; +``` + +5. In `openDetail`, replace `showModal.value = true;` with: + +```typescript +detailPreviousView.value = activeTab.value === "home" ? "home" : "list"; +currentView.value = "detail"; +``` + +6. Replace `closeDetail` with: + +```typescript +const closeDetail = () => { + currentView.value = detailPreviousView.value; + currentApp.value = null; +}; +``` + +7. Replace `if (showModal.value && currentApp.value)` checks with `if (currentView.value === "detail" && currentApp.value)`. +8. Remove the old `` block. + +- [ ] **Step 7: Run detail tests** + +Run: `npm run test -- src/__tests__/unit/appIdentity.test.ts src/__tests__/unit/AppDetailPage.test.ts` + +Expected: PASS. + +- [ ] **Step 8: Commit main-content detail page** + +Run: + +```bash +git add src/modules/appIdentity.ts src/components/AppDetailPage.vue src/App.vue src/__tests__/unit/appIdentity.test.ts src/__tests__/unit/AppDetailPage.test.ts +git commit -m "feat(detail): move app details into content view" +``` + +Expected: commit succeeds. + +## Task 4: Add Favorites API UI And Availability Resolver + +**Files:** +- Create: `src/modules/favoriteAvailability.ts` +- Create: `src/components/FavoriteFolderSelector.vue` +- Create: `src/components/FavoriteFolderManager.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/favoriteAvailability.test.ts` +- Test: `src/__tests__/unit/FavoriteFolderManager.test.ts` + +- [ ] **Step 1: Write failing favorite availability tests** + +Create `src/__tests__/unit/favoriteAvailability.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { resolveFavoriteItems } from "@/modules/favoriteAvailability"; +import type { App, FavoriteItem } from "@/global/typedefinition"; + +const app = (origin: "spark" | "apm", overrides: Partial = {}): App => ({ + name: "WPS", + pkgname: "wps", + version: "1.0.0", + filename: "wps_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: "", + tags: "", + img_urls: [], + icons: "", + category: "office", + origin, + currentStatus: "not-installed", + arch: "amd64", + ...overrides, +}); + +const favorite: FavoriteItem = { + id: 1, + appKey: "app:office:wps", + pkgname: "wps", + name: "WPS", + category: "office", + iconUrl: "", + createdAt: "2026-05-18T00:00:00Z", +}; + +describe("favoriteAvailability", () => { + it("marks downlisted favorites", () => { + expect(resolveFavoriteItems([favorite], [], [], { spark: true, apm: true }, "both")[0].status).toBe("downlisted"); + }); + + it("selects preferred installable variant", () => { + const resolved = resolveFavoriteItems([favorite], [app("spark"), app("apm")], [], { spark: true, apm: true }, "both")[0]; + expect(resolved.status).toBe("installable"); + expect(resolved.selectedApp?.origin).toBe("apm"); + }); + + it("marks installed favorites", () => { + const resolved = resolveFavoriteItems([favorite], [app("apm")], [app("apm", { currentStatus: "installed" })], { spark: true, apm: true }, "both")[0]; + expect(resolved.status).toBe("installed"); + }); +}); +``` + +- [ ] **Step 2: Write failing folder manager test** + +Create `src/__tests__/unit/FavoriteFolderManager.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import FavoriteFolderManager from "@/components/FavoriteFolderManager.vue"; +import type { FavoriteFolder, ResolvedFavoriteItem } from "@/global/typedefinition"; + +const folder: FavoriteFolder = { + id: 1, + name: "默认收藏夹", + itemCount: 1, + createdAt: "2026-05-18T00:00:00Z", + updatedAt: "2026-05-18T00:00:00Z", +}; + +const item: ResolvedFavoriteItem = { + item: { + id: 2, + appKey: "app:office:wps", + pkgname: "wps", + name: "WPS", + category: "office", + iconUrl: "", + createdAt: "2026-05-18T00:00:00Z", + }, + status: "downlisted", + reason: "已下架", + selectedApp: null, +}; + +describe("FavoriteFolderManager", () => { + it("shows downlisted favorites and emits bulk delete", async () => { + const rendered = render(FavoriteFolderManager, { + props: { folders: [folder], activeFolderId: 1, items: [item], loading: false, error: "" }, + }); + + expect(screen.getByText("已下架")).toBeTruthy(); + await fireEvent.click(screen.getByLabelText("选择 WPS")); + await fireEvent.click(screen.getByRole("button", { name: "移除选中" })); + + expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]); + }); +}); +``` + +- [ ] **Step 3: Run favorite tests and verify failure** + +Run: `npm run test -- src/__tests__/unit/favoriteAvailability.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts` + +Expected: FAIL because modules/components do not exist. + +- [ ] **Step 4: Add favorite availability resolver** + +Create `src/modules/favoriteAvailability.ts` with: + +```typescript +import type { App, FavoriteItem, ResolvedFavoriteItem, StoreFilter } from "@/global/typedefinition"; +import { getHybridDefaultOrigin } from "@/global/storeConfig"; + +const sourceEnabled = (origin: "spark" | "apm", available: { spark: boolean; apm: boolean }, storeFilter: StoreFilter) => { + if (origin === "spark") return available.spark && storeFilter !== "apm"; + return available.apm && storeFilter !== "spark"; +}; + +const hasCurrentArch = (app: App, clientArch: string) => { + return !app.arch || app.arch === clientArch || app.filename.includes(`_${clientArch}.`); +}; + +const choosePreferred = (apps: App[]): App => { + if (apps.length === 1) return apps[0]; + const spark = apps.find((app) => app.origin === "spark"); + const apm = apps.find((app) => app.origin === "apm"); + if (spark && apm) return getHybridDefaultOrigin(spark) === "spark" ? spark : apm; + return apps[0]; +}; + +export const resolveFavoriteItems = ( + items: FavoriteItem[], + catalogApps: App[], + installedApps: App[], + available: { spark: boolean; apm: boolean }, + storeFilter: StoreFilter, + clientArch = window.apm_store.arch || "amd64", +): ResolvedFavoriteItem[] => { + return items.map((item) => { + const matches = catalogApps.filter((app) => app.pkgname === item.pkgname && app.category === item.category); + if (matches.length === 0) return { item, status: "downlisted", reason: "已下架", selectedApp: null }; + + const installed = installedApps.find((app) => app.pkgname === item.pkgname && app.category === item.category && app.currentStatus === "installed"); + if (installed) return { item, status: "installed", reason: "已安装", selectedApp: installed }; + + const archMatches = matches.filter((app) => hasCurrentArch(app, clientArch)); + if (archMatches.length === 0) return { item, status: "arch-unavailable", reason: "当前架构不可用", selectedApp: null }; + + const usable = archMatches.filter((app) => sourceEnabled(app.origin, available, storeFilter)); + if (usable.length === 0) return { item, status: "platform-unavailable", reason: "当前来源不可用", selectedApp: null }; + + return { item, status: "installable", reason: "可安装", selectedApp: choosePreferred(usable) }; + }); +}; +``` + +- [ ] **Step 5: Create favorite folder selector** + +Create `src/components/FavoriteFolderSelector.vue` with: + +```vue + + + +``` + +- [ ] **Step 6: Create favorite folder manager** + +Create `src/components/FavoriteFolderManager.vue` with: + +```vue + + + +``` + +- [ ] **Step 7: Wire favorite selector and manager in App.vue** + +Modify `src/App.vue`: + +1. Import components/helpers/API: + +```typescript +import FavoriteFolderSelector from "./components/FavoriteFolderSelector.vue"; +import FavoriteFolderManager from "./components/FavoriteFolderManager.vue"; +import { addFavoriteItem, bulkDeleteFavoriteItems, createFavoriteFolder, listFavoriteFolders, listFavoriteItems } from "./modules/backendApi"; +import { buildFavoriteAppKey } from "./modules/appIdentity"; +import { resolveFavoriteItems } from "./modules/favoriteAvailability"; +import type { FavoriteFolder, FavoriteItem, ResolvedFavoriteItem } from "./global/typedefinition"; +``` + +2. Add state: + +```typescript +const favoriteFolders = ref([]); +const activeFavoriteFolderId = ref(null); +const favoriteItems = ref([]); +const showFavoriteSelector = ref(false); +const favoriteTargetApp = ref(null); +const favoritesLoading = ref(false); +const favoritesError = ref(""); +``` + +3. Add computed resolver: + +```typescript +const resolvedFavoriteItems = computed(() => + resolveFavoriteItems( + favoriteItems.value, + apps.value, + installedApps.value, + availableSources.value, + storeFilter.value, + window.apm_store.arch || "amd64", + ), +); +``` + +4. Add handlers: + +```typescript +const loadFavoriteFolders = async () => { + if (!isLoggedIn.value) return; + favoriteFolders.value = await listFavoriteFolders(); + if (!activeFavoriteFolderId.value && favoriteFolders.value.length > 0) activeFavoriteFolderId.value = favoriteFolders.value[0].id; +}; + +const loadFavoriteItems = async (folderId: number) => { + favoritesLoading.value = true; + favoritesError.value = ""; + try { + activeFavoriteFolderId.value = folderId; + favoriteItems.value = await listFavoriteItems(folderId); + } catch (error) { + favoritesError.value = error instanceof Error ? error.message : "读取收藏失败"; + } finally { + favoritesLoading.value = false; + } +}; + +const openFavoriteSelector = async (app: App) => { + if (!requireLogin("收藏应用需要登录星火账号。")) return; + favoriteTargetApp.value = app; + await loadFavoriteFolders(); + showFavoriteSelector.value = true; +}; + +const addCurrentFavoriteToFolder = async (folderId: number | "default") => { + if (!favoriteTargetApp.value) return; + const app = favoriteTargetApp.value; + await addFavoriteItem(folderId, { + appKey: buildFavoriteAppKey(app), + pkgname: app.pkgname, + name: app.name, + category: app.category, + iconUrl: app.icons || "", + }); + showFavoriteSelector.value = false; + await loadFavoriteFolders(); +}; + +const createFavoriteFolderFromPrompt = async () => { + const name = window.prompt("收藏夹名称"); + if (!name?.trim()) return; + const folder = await createFavoriteFolder(name.trim()); + await loadFavoriteFolders(); + await loadFavoriteItems(folder.id); +}; + +const removeSelectedFavorites = async (itemIds: number[]) => { + if (!activeFavoriteFolderId.value || itemIds.length === 0) return; + await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, itemIds); + await loadFavoriteItems(activeFavoriteFolderId.value); + await loadFavoriteFolders(); +}; + +const installResolvedFavorites = async (items: ResolvedFavoriteItem[]) => { + for (const item of items) { + if (item.selectedApp) await onDetailInstall(item.selectedApp); + } +}; + +const openFavoriteManagement = async () => { + if (!requireLogin("我的收藏需要登录星火账号。")) return; + currentView.value = "favorites"; + activeTab.value = "favorites"; + await loadFavoriteFolders(); + if (activeFavoriteFolderId.value) await loadFavoriteItems(activeFavoriteFolderId.value); +}; +``` + +5. Add `FavoriteFolderSelector` near modals: + +```vue + +``` + +6. Add favorites content branch: + +```vue + +``` + +- [ ] **Step 8: Run favorite tests** + +Run: `npm run test -- src/__tests__/unit/favoriteAvailability.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts` + +Expected: PASS. + +- [ ] **Step 9: Commit favorites UI and resolver** + +Run: + +```bash +git add src/modules/favoriteAvailability.ts src/components/FavoriteFolderSelector.vue src/components/FavoriteFolderManager.vue src/App.vue src/__tests__/unit/favoriteAvailability.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts +git commit -m "feat(favorites): add cloud favorite management" +``` + +Expected: commit succeeds. + +## Task 5: Add Reviews Panel And Downloaded Record Writes + +**Files:** +- Create: `src/components/ReviewsPanel.vue` +- Modify: `src/components/AppDetailPage.vue` +- Modify: `src/modules/processInstall.ts` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/ReviewsPanel.test.ts` +- Test: `src/__tests__/unit/processInstall.test.ts` + +- [ ] **Step 1: Write failing ReviewsPanel test** + +Create `src/__tests__/unit/ReviewsPanel.test.ts` with: + +```typescript +import { render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import ReviewsPanel from "@/components/ReviewsPanel.vue"; +import type { ReviewTags } from "@/global/typedefinition"; + +const tags: ReviewTags = { + origin: "apm", + category: "office", + pkgname: "wps", + version: "1.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", +}; + +describe("ReviewsPanel", () => { + it("shows anonymous login prompt and read-only review tags", () => { + render(ReviewsPanel, { props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: false } }); + + expect(screen.getByText("登录后发表评论")).toBeTruthy(); + expect(screen.getByText("1.0.0")).toBeTruthy(); + expect(screen.getByText("deepin 25")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Extend processInstall test for queue result** + +Append to `src/__tests__/unit/processInstall.test.ts`: + +```typescript + it("returns queued download metadata for account records", async () => { + vi.doMock("axios", () => ({ + default: { + create: vi.fn(() => ({ post: vi.fn(() => Promise.resolve({ data: { ok: true } })) })), + }, + })); + Object.assign(window.ipcRenderer, { on: vi.fn(), send: vi.fn(), invoke: vi.fn() }); + window.apm_store.arch = "amd64"; + const { handleInstall } = await import("@/modules/processInstall"); + + const result = await handleInstall({ + name: "WPS", + pkgname: "wps", + version: "1.0.0", + filename: "wps_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: "", + tags: "", + img_urls: [], + icons: "", + category: "office", + origin: "apm", + currentStatus: "not-installed", + }); + + expect(result?.pkgname).toBe("wps"); + expect(result?.origin).toBe("apm"); + }); +``` + +- [ ] **Step 3: Run review/download tests and verify failure** + +Run: `npm run test -- src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/processInstall.test.ts` + +Expected: FAIL because `ReviewsPanel` does not exist and `handleInstall` returns `undefined`. + +- [ ] **Step 4: Create ReviewsPanel** + +Create `src/components/ReviewsPanel.vue` with: + +```vue + + + +``` + +- [ ] **Step 5: Mount reviews in detail page** + +Modify `src/components/AppDetailPage.vue`: + +1. Import `ReviewsPanel` and `ReviewTags`. +2. Add below screenshots block: + +```vue + +``` + +- [ ] **Step 6: Return queued download from processInstall** + +Modify `src/modules/processInstall.ts`: + +1. Change signature: + +```typescript +export const handleInstall = async (appObj?: App): Promise => { +``` + +2. Replace early bare `return;` statements with `return null;`. +3. After `window.ipcRenderer.send("queue-install", JSON.stringify(download));`, add: + +```typescript + return download; +``` + +4. Keep statistics POST non-blocking after the return by moving the statistics call before return or by storing the promise before return. The install queue send must remain unchanged. + +- [ ] **Step 7: Record cloud downloaded apps in App.vue** + +Modify `src/App.vue`: + +1. Extend existing imports: + +```typescript +import { buildFavoriteAppKey, buildReviewAppKey, buildReviewTags, getDisplayApp, parsePackageArch } from "./modules/appIdentity"; +import { recordDownloadedApp } from "./modules/backendApi"; +import type { SystemInfo } from "./global/typedefinition"; +``` + +2. Add system info state: + +```typescript +const systemInfo = ref({ distro: "unknown" }); +``` + +3. Add computed review props: + +```typescript +const currentDisplayAppForReview = computed(() => getDisplayApp(currentApp.value)); +const currentReviewAppKey = computed(() => { + const app = currentDisplayAppForReview.value; + return app ? buildReviewAppKey(app, window.apm_store.arch || "amd64") : ""; +}); +const currentReviewTags = computed(() => { + const app = currentDisplayAppForReview.value; + return app ? buildReviewTags(app, { clientArch: window.apm_store.arch || "amd64", distro: systemInfo.value.distro }) : null; +}); +``` + +4. On mount, before data loading completes, fetch system info: + +```typescript +systemInfo.value = await window.ipcRenderer.invoke("get-system-info").catch(() => ({ distro: "unknown" })); +``` + +5. Replace `onDetailInstall` with: + +```typescript +const onDetailInstall = async (app: App) => { + const download = await handleInstall(app); + if (!download || !isLoggedIn.value) return; + try { + await recordDownloadedApp({ + appKey: buildFavoriteAppKey(app), + pkgname: app.pkgname, + name: app.name, + category: app.category, + selectedOrigin: app.origin, + version: app.version || "", + packageArch: app.arch || parsePackageArch(app.filename), + }); + } catch (error) { + logger.warn(`记录下载历史失败: ${error}`); + } +}; +``` + +- [ ] **Step 8: Add system info IPC** + +Modify `electron/main/index.ts` near `get-app-version`: + +```typescript +const getSystemInfo = (): { distro: string } => { + try { + const raw = fs.readFileSync("/etc/os-release", "utf8"); + const values = Object.fromEntries( + raw + .split("\n") + .filter((line) => line.includes("=")) + .map((line) => { + const [key, ...rest] = line.split("="); + return [key, rest.join("=").replace(/^"|"$/g, "")]; + }), + ); + return { distro: values.PRETTY_NAME || values.NAME || values.ID || "unknown" }; + } catch { + return { distro: "unknown" }; + } +}; + +ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo()); +``` + +- [ ] **Step 9: Run review/download tests** + +Run: `npm run test -- src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/processInstall.test.ts` + +Expected: PASS. + +- [ ] **Step 10: Commit reviews and downloaded records** + +Run: + +```bash +git add electron/main/index.ts src/components/ReviewsPanel.vue src/components/AppDetailPage.vue src/modules/processInstall.ts src/App.vue src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/processInstall.test.ts +git commit -m "feat(account): record downloads and show reviews" +``` + +Expected: commit succeeds. + +## Task 6: Add User Management, Downloaded History, And Sync Preference + +**Files:** +- Create: `src/global/accountSyncState.ts` +- Create: `src/components/UserManagementView.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/UserManagementView.test.ts` + +- [ ] **Step 1: Write failing user management test** + +Create `src/__tests__/unit/UserManagementView.test.ts` with: + +```typescript +import { render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import UserManagementView from "@/components/UserManagementView.vue"; +import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition"; + +const user: SparkUser = { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + forumLevel: "管理员", + forumGroups: ["管理员"], +}; + +const download: DownloadedAppRecord = { + id: 1, + appKey: "app:office:wps", + pkgname: "wps", + name: "WPS", + category: "office", + selectedOrigin: "apm", + version: "1.0.0", + packageArch: "amd64", + downloadedAt: "2026-05-18T00:00:00Z", +}; + +describe("UserManagementView", () => { + it("renders profile, forum level, links, downloads, and sync preference", () => { + render(UserManagementView, { + props: { user, downloadedApps: [download], syncEnabled: true, loading: false, error: "" }, + }); + + expect(screen.getByText("Momen")).toBeTruthy(); + expect(screen.getByText("管理员")).toBeTruthy(); + expect(screen.getByText("论坛首页")).toBeTruthy(); + expect(screen.getByText("修改论坛资料")).toBeTruthy(); + expect(screen.getByText("WPS")).toBeTruthy(); + expect(screen.getByLabelText("自动同步已安装应用")).toBeChecked(); + }); +}); +``` + +- [ ] **Step 2: Run user management test and verify failure** + +Run: `npm run test -- src/__tests__/unit/UserManagementView.test.ts` + +Expected: FAIL because `UserManagementView` does not exist. + +- [ ] **Step 3: Add sync preference helper** + +Create `src/global/accountSyncState.ts` with: + +```typescript +import { ref, watch } from "vue"; + +const STORAGE_KEY = "spark-store-installed-sync-enabled"; + +const readSyncEnabled = (): boolean | null => { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === "true") return true; + if (raw === "false") return false; + return null; +}; + +export const installedSyncEnabled = ref(readSyncEnabled()); + +export const setInstalledSyncEnabled = (enabled: boolean) => { + installedSyncEnabled.value = enabled; + localStorage.setItem(STORAGE_KEY, enabled ? "true" : "false"); +}; + +watch(installedSyncEnabled, (enabled) => { + if (enabled === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, enabled ? "true" : "false"); +}); +``` + +- [ ] **Step 4: Create user management view** + +Create `src/components/UserManagementView.vue` with: + +```vue + + + +``` + +- [ ] **Step 5: Wire user management in App.vue** + +Modify `src/App.vue`: + +1. Import: + +```typescript +import UserManagementView from "./components/UserManagementView.vue"; +import { installedSyncEnabled, setInstalledSyncEnabled } from "./global/accountSyncState"; +import { listDownloadedApps } from "./modules/backendApi"; +import type { DownloadedAppRecord } from "./global/typedefinition"; +``` + +2. Add state: + +```typescript +const downloadedApps = ref([]); +const downloadedLoading = ref(false); +const downloadedError = ref(""); +``` + +3. Add handler: + +```typescript +const loadDownloadedHistory = async () => { + if (!isLoggedIn.value) return; + downloadedLoading.value = true; + downloadedError.value = ""; + try { + const list = await listDownloadedApps(1, 50); + downloadedApps.value = list.items; + } catch (error) { + downloadedError.value = error instanceof Error ? error.message : "读取下载历史失败"; + } finally { + downloadedLoading.value = false; + } +}; +``` + +4. Change `openUserManagement` to: + +```typescript +const openUserManagement = async () => { + if (!requireLogin("用户管理需要登录星火账号。")) return; + currentView.value = "account"; + activeTab.value = "account"; + await loadDownloadedHistory(); +}; +``` + +5. Add content branch before favorites branch: + +```vue + +``` + +- [ ] **Step 6: Run user management tests** + +Run: `npm run test -- src/__tests__/unit/UserManagementView.test.ts` + +Expected: PASS. + +- [ ] **Step 7: Commit user management** + +Run: + +```bash +git add src/global/accountSyncState.ts src/components/UserManagementView.vue src/App.vue src/__tests__/unit/UserManagementView.test.ts +git commit -m "feat(account): add user management view" +``` + +Expected: commit succeeds. + +## Task 7: Add Installed-App Cloud Sync And Restore + +**Files:** +- Create: `src/modules/appListSync.ts` +- Create: `src/components/AppListRestoreModal.vue` +- Modify: `src/components/InstalledAppsModal.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/appListSync.test.ts` +- Test: `src/__tests__/unit/AppListRestoreModal.test.ts` +- Test: `src/__tests__/unit/InstalledAppsModal.test.ts` + +- [ ] **Step 1: Write failing app-list sync test** + +Create `src/__tests__/unit/appListSync.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { buildSyncItems } from "@/modules/appListSync"; +import type { App } from "@/global/typedefinition"; + +const baseApp: App = { + name: "Spark Notes", + pkgname: "spark-notes", + version: "1.0.0", + filename: "spark-notes_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: "", + tags: "", + img_urls: [], + icons: "https://example.invalid/icon.png", + category: "office", + origin: "spark", + currentStatus: "installed", +}; + +describe("appListSync", () => { + it("syncs only store-recognized non-dependency apps", () => { + const items = buildSyncItems([ + baseApp, + { ...baseApp, pkgname: "unknown", category: "unknown" }, + { ...baseApp, pkgname: "dep", isDependency: true }, + { ...baseApp, pkgname: "not-installed", currentStatus: "not-installed" }, + ]); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ pkgname: "spark-notes", origin: "spark", category: "office" }); + }); +}); +``` + +- [ ] **Step 2: Write failing restore modal test** + +Create `src/__tests__/unit/AppListRestoreModal.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import AppListRestoreModal from "@/components/AppListRestoreModal.vue"; + +describe("AppListRestoreModal", () => { + it("emits selected installable items", async () => { + const rendered = render(AppListRestoreModal, { + props: { + show: true, + loading: false, + error: "", + items: [{ pkgname: "spark-notes", origin: "spark", category: "office", version: "1.0.0", packageArch: "amd64", appName: "Spark Notes", iconUrl: "" }], + installedKeys: [], + }, + }); + + await fireEvent.click(screen.getByLabelText("选择 Spark Notes")); + await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" })); + + expect(rendered.emitted("install-selected")?.[0]?.[0]).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 3: Run sync/restore tests and verify failure** + +Run: `npm run test -- src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts` + +Expected: FAIL because module/component do not exist. + +- [ ] **Step 4: Add sync filtering module** + +Create `src/modules/appListSync.ts` with: + +```typescript +import type { App, SyncedAppListItem } from "@/global/typedefinition"; +import { parsePackageArch } from "@/modules/appIdentity"; + +export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => { + return apps + .filter((app) => app.currentStatus === "installed") + .filter((app) => app.category !== "unknown") + .filter((app) => !app.isDependency) + .filter((app) => Boolean(app.pkgname && app.origin)) + .map((app) => ({ + pkgname: app.pkgname, + origin: app.origin, + category: app.category, + version: app.version || "", + packageArch: app.arch || parsePackageArch(app.filename), + appName: app.name || app.pkgname, + iconUrl: app.icons || "", + })); +}; + +export const cloudItemKey = (item: Pick): string => `${item.origin}:${item.pkgname}`; +``` + +- [ ] **Step 5: Create restore modal** + +Create `src/components/AppListRestoreModal.vue` with: + +```vue + + + +``` + +- [ ] **Step 6: Add sync buttons to InstalledAppsModal** + +Modify `src/components/InstalledAppsModal.vue`: + +1. Add props: + +```typescript + loggedIn: boolean; + syncing: boolean; +``` + +2. Add emits: + +```typescript + (e: "sync-to-account"): void; + (e: "restore-from-account"): void; + (e: "request-login"): void; +``` + +3. Add buttons before `刷新`: + +```vue + + +``` + +4. Update every `InstalledAppsModal.test.ts` render props to include `loggedIn: false` and `syncing: false`. + +- [ ] **Step 7: Wire sync/restore and startup prompt in App.vue** + +Modify `src/App.vue`: + +1. Import: + +```typescript +import AppListRestoreModal from "./components/AppListRestoreModal.vue"; +import { buildSyncItems, cloudItemKey } from "./modules/appListSync"; +import { fetchSyncedAppList, uploadSyncedAppList } from "./modules/backendApi"; +import type { SyncedAppListItem } from "./global/typedefinition"; +``` + +2. Add state: + +```typescript +const syncLoading = ref(false); +const restoreLoading = ref(false); +const restoreError = ref(""); +const showRestoreModal = ref(false); +const restoreItems = ref([]); +``` + +3. Add computed installed keys: + +```typescript +const installedCloudKeys = computed(() => installedApps.value.map((app) => `${app.origin}:${app.pkgname}`)); +``` + +4. Pass props/events to `InstalledAppsModal`: + +```vue +:logged-in="isLoggedIn" +:syncing="syncLoading" +@sync-to-account="syncInstalledAppsToAccount" +@restore-from-account="openRestoreFromAccount" +@request-login="requireLogin('云端同步需要登录星火账号。')" +``` + +5. Mount restore modal: + +```vue + +``` + +6. Add the real sync handlers: + +```typescript +const syncInstalledAppsToAccount = async () => { + if (!requireLogin("云端同步需要登录星火账号。")) return; + syncLoading.value = true; + try { + const items = buildSyncItems(installedApps.value); + await uploadSyncedAppList({ clientArch: window.apm_store.arch || "amd64", distro: systemInfo.value.distro, items }); + } finally { + syncLoading.value = false; + } +}; + +const openRestoreFromAccount = async () => { + if (!requireLogin("从账号恢复应用需要登录星火账号。")) return; + restoreLoading.value = true; + restoreError.value = ""; + showRestoreModal.value = true; + try { + const list = await fetchSyncedAppList(); + restoreItems.value = list?.items || []; + } catch (error) { + restoreError.value = error instanceof Error ? error.message : "读取云端列表失败"; + } finally { + restoreLoading.value = false; + } +}; + +const installCloudItems = async (items: SyncedAppListItem[]) => { + for (const item of items) { + const app = apps.value.find((candidate) => candidate.pkgname === item.pkgname && candidate.origin === item.origin && candidate.category === item.category); + if (app) await onDetailInstall(app); + } +}; +``` + +7. Add `@sync-now="syncInstalledAppsToAccount"` to the existing `UserManagementView` branch created in Task 6. + +8. After catalog load completes in `onMounted`, add: + +```typescript +if (isLoggedIn.value && installedSyncEnabled.value === null) { + const enabled = window.confirm("是否启用已安装应用列表自动同步到星火账号?仅同步商店识别的非依赖应用。"); + setInstalledSyncEnabled(enabled); +} +if (isLoggedIn.value && installedSyncEnabled.value === true) { + await refreshInstalledApps(); + await syncInstalledAppsToAccount(); +} +``` + +- [ ] **Step 8: Run sync tests** + +Run: + +```bash +npm run test -- src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts +``` + +Expected: PASS. + +- [ ] **Step 9: Commit installed sync and restore** + +Run: + +```bash +git add src/modules/appListSync.ts src/components/AppListRestoreModal.vue src/components/InstalledAppsModal.vue src/App.vue src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts +git commit -m "feat(sync): add installed app cloud sync" +``` + +Expected: commit succeeds. + +## Task 8: Final Client Verification + +**Files:** +- Verify only. + +- [ ] **Step 1: Run account-related unit tests** + +Run: + +```bash +npm run test -- src/__tests__/unit/accountTypes.test.ts src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/appIdentity.test.ts src/__tests__/unit/AppDetailPage.test.ts src/__tests__/unit/favoriteAvailability.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/UserManagementView.test.ts src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts src/__tests__/unit/processInstall.test.ts +``` + +Expected: all listed tests PASS. + +- [ ] **Step 2: Run full unit suite** + +Run: `npm run test -- --run` + +Expected: all unit tests PASS. + +- [ ] **Step 3: Run lint** + +Run: `npm run lint` + +Expected: exits 0. + +- [ ] **Step 4: Run Vite build** + +Run: `npm run build:vite` + +Expected: exits 0. + +- [ ] **Step 5: Check worktree** + +Run: `git status --short --branch` + +Expected: branch shows only intentional commits and no `.superpowers/` artifacts. + +## Self-Review Checklist + +Spec coverage: + +- Clean client repo/worktree requirement: file structure and final verification assume `/home/spark/Desktop/shenmo-spark-store/spark-store`. +- Forum login and external registration: Task 2. +- Sidebar account entry, avatar/name, and quick menu: Task 2. +- Anonymous base browsing/search/detail/install/remove/update/installed viewing: Tasks 2, 3, 5, and 7 gate only account-only actions. +- Detail page inside content area with back: Task 3. +- Favorite add, folder selection, default folder: Task 4. +- Favorites as app-level identities: Tasks 3 and 4 use `app:{category}:{pkgname}`. +- Downlisted/unavailable favorite visibility and bulk remove: Task 4. +- Batch install from favorites using current priority: Task 4 resolver uses `getHybridDefaultOrigin` and source availability. +- Reviews/comments with anonymous prompt: Task 5. +- Downloaded records written only for logged-in installs: Task 5. +- User management profile, forum level, links, downloads, sync preference: Task 6. +- Startup installed sync ask-once and non-dependency store-recognized filtering: Task 7. +- Existing install/update IPC contracts preserved: Tasks 5 and 7 reuse `handleInstall` and do not alter queue payload schema. + +Placeholder scan: + +- No `TBD`, `TODO`, `implement later`, or placeholder test bodies remain. +- Long component tasks include concrete code blocks for the behavior under test; existing surrounding markup can be adjusted during implementation without changing the defined contracts. + +Type consistency: + +- Backend snake_case fields are mapped to client camelCase only inside `backendApi.ts`. +- Favorite key is always `app:{category}:{pkgname}`. +- Review key remains `{origin}:{store_arch}:{category}:{pkgname}`. +- `window.apm_store.arch` is treated as bare architecture such as `amd64`. +- Store filter uses existing `StoreFilter = "spark" | "apm" | "both"`. diff --git a/docs/superpowers/plans/2026-05-18-spark-client-account-reviews-sync.md b/docs/superpowers/plans/2026-05-18-spark-client-account-reviews-sync.md new file mode 100644 index 00000000..763c4252 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-spark-client-account-reviews-sync.md @@ -0,0 +1,1481 @@ +# Spark Client Account Reviews Sync Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Integrate Spark Store's Electron/Vue client with the new backend for Flarum login, profile display, app reviews/ratings, immutable review tags, and user app-list sync/restore. + +**Architecture:** Keep backend API access in focused TypeScript modules, keep global auth state in one Vue module, and add small child components instead of expanding the already-large `AppDetailModal.vue` and `InstalledAppsModal.vue`. Local system facts such as distro are exposed through Electron IPC and combined with app metadata to create immutable review tags. + +**Tech Stack:** Electron 40, Vue 3 Composition API, TypeScript strict mode, Axios, Vitest, Testing Library Vue, existing IPC facade and install queue. + +--- + +## File Structure + +Existing files to modify: + +- Modify: `.gitignore` - ignore `.superpowers/` visual companion artifacts. +- Modify: `electron/main/index.ts` - add `get-system-info` IPC handler that safely reads `/etc/os-release`. +- Modify: `src/vite-env.d.ts` - add auth/backend/system-info types. +- Modify: `src/global/typedefinition.ts` - add user, review, rating, app-list sync types. +- Modify: `src/global/storeConfig.ts` - add `SPARK_BACKEND_BASE_URL` config. +- Modify: `src/components/AppHeader.vue` - show login/profile action. +- Modify: `src/components/AppDetailModal.vue` - mount `ReviewsPanel` with the active display app. +- Modify: `src/components/InstalledAppsModal.vue` - add sync/restore action buttons and restore modal entry events. +- Modify: `src/App.vue` - coordinate auth modal, review props, app-list sync/restore flow, and system info loading. +- Modify: `src/__tests__/setup.ts` - extend window mocks. + +New files to create: + +- Create: `src/global/authState.ts` - auth state, persistence, login/logout helpers. +- Create: `src/modules/backendApi.ts` - Axios client and backend request helpers. +- Create: `src/modules/reviewTags.ts` - immutable review tag construction and app key creation. +- Create: `src/modules/appListSync.ts` - store-recognized installed-app filtering and restore-plan helpers. +- Create: `src/components/LoginModal.vue` - Flarum login UI. +- Create: `src/components/ReviewsPanel.vue` - rating summary, filters, review list, composer. +- Create: `src/components/AppListRestoreModal.vue` - cloud app-list restore selector. +- Create: `src/__tests__/unit/reviewTags.test.ts` - immutable tag tests. +- Create: `src/__tests__/unit/appListSync.test.ts` - sync filtering tests. +- Create: `src/__tests__/unit/LoginModal.test.ts` - login UI tests. +- Create: `src/__tests__/unit/ReviewsPanel.test.ts` - review panel state tests. +- Create: `src/__tests__/unit/AppListRestoreModal.test.ts` - restore modal tests. + +## Task 1: Add Backend Config And Shared Types + +**Files:** +- Modify: `.gitignore` +- Modify: `src/global/storeConfig.ts` +- Modify: `src/global/typedefinition.ts` +- Modify: `src/vite-env.d.ts` +- Test: `src/__tests__/setup.ts` + +- [ ] **Step 1: Write type/import smoke test** + +Create `src/__tests__/unit/accountTypes.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig"; +import type { ReviewTags, SparkUser } from "@/global/typedefinition"; + +describe("account backend types", () => { + it("exports backend url and account types", () => { + const user: SparkUser = { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + }; + const tags: ReviewTags = { + origin: "apm", + category: "office", + pkgname: "wps", + version: "1.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", + }; + + expect(typeof SPARK_BACKEND_BASE_URL).toBe("string"); + expect(user.displayName).toBe("Momen"); + expect(tags.packageArch).toBe("amd64"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/accountTypes.test.ts` + +Expected: FAIL because `SPARK_BACKEND_BASE_URL`, `SparkUser`, and `ReviewTags` do not exist. + +- [ ] **Step 3: Ignore visual companion artifacts** + +Modify `.gitignore` by adding: + +```gitignore +.superpowers/ +``` + +- [ ] **Step 4: Add backend config** + +Modify `src/global/storeConfig.ts` by adding after `APM_STORE_STATS_BASE_URL`: + +```typescript +export const SPARK_BACKEND_BASE_URL: string = + import.meta.env.VITE_SPARK_BACKEND_BASE_URL || ""; +``` + +- [ ] **Step 5: Add shared account types** + +Append to `src/global/typedefinition.ts`: + +```typescript +export interface SparkUser { + id: number; + flarumUserId: string; + username: string; + displayName: string; + avatarUrl: string; +} + +export interface AuthSession { + accessToken: string; + tokenType: "bearer"; + user: SparkUser; +} + +export interface ReviewTags { + origin: "spark" | "apm"; + category: string; + pkgname: string; + version: string; + packageArch: string; + clientArch: string; + distro: string; +} + +export interface RatingSummary { + averageRating: number; + reviewCount: number; + starCounts: Record; +} + +export interface AppReview { + id: number; + rating: number; + content: string; + version: string; + packageArch: string; + clientArch: string; + distro: string; + origin: "spark" | "apm"; + category: string; + createdAt: string; + updatedAt: string; + userDisplayName: string; + userAvatarUrl: string; +} + +export interface SyncedAppListItem { + id?: number; + pkgname: string; + origin: "spark" | "apm"; + category: string; + version: string; + packageArch: string; + appName: string; + iconUrl: string; +} + +export interface SyncedAppList { + snapshotName: string; + clientArch: string; + distro: string; + updatedAt: string; + items: SyncedAppListItem[]; +} + +export interface SystemInfo { + distro: string; +} +``` + +- [ ] **Step 6: Extend environment declarations** + +Modify `src/vite-env.d.ts` by adding to `ImportMetaEnv`: + +```typescript +interface ImportMetaEnv { + readonly VITE_SPARK_BACKEND_BASE_URL?: string; +} +``` + +- [ ] **Step 7: Extend test setup mocks** + +Modify `src/__tests__/setup.ts` so `window.apm_store.arch` is `amd64`, not `amd64-store`, and `ipcRenderer.invoke` can be overridden per test: + +```typescript +Object.defineProperty(window, "apm_store", { + value: { + arch: "amd64", + }, + writable: true, +}); +``` + +- [ ] **Step 8: Run test to verify pass** + +Run: `npm run test -- src/__tests__/unit/accountTypes.test.ts` + +Expected: PASS. + +- [ ] **Step 9: Commit shared config/types** + +Run: + +```bash +git add .gitignore src/global/storeConfig.ts src/global/typedefinition.ts src/vite-env.d.ts src/__tests__/setup.ts src/__tests__/unit/accountTypes.test.ts +git commit -m "feat(account): add backend config and types" +``` + +Expected: commit succeeds if the user requested commits for implementation execution. + +## Task 2: Add Backend API Client And Auth State + +**Files:** +- Create: `src/modules/backendApi.ts` +- Create: `src/global/authState.ts` +- Test: `src/__tests__/unit/authState.test.ts` + +- [ ] **Step 1: Write failing auth state test** + +Create `src/__tests__/unit/authState.test.ts` with: + +```typescript +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("authState", () => { + beforeEach(() => { + vi.resetModules(); + localStorage.clear(); + }); + + it("persists and clears a backend session", async () => { + const { authSession, setAuthSession, logout } = await import("@/global/authState"); + + setAuthSession({ + accessToken: "jwt", + tokenType: "bearer", + user: { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + }, + }); + + expect(authSession.value?.accessToken).toBe("jwt"); + expect(JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken).toBe("jwt"); + + logout(); + + expect(authSession.value).toBeNull(); + expect(localStorage.getItem("spark-store-auth")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/authState.test.ts` + +Expected: FAIL because `authState` module does not exist. + +- [ ] **Step 3: Add backend API client** + +Create `src/modules/backendApi.ts` with: + +```typescript +import axios from "axios"; + +import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig"; +import type { + AppReview, + AuthSession, + RatingSummary, + ReviewTags, + SyncedAppList, + SyncedAppListItem, +} from "@/global/typedefinition"; + +const backend = axios.create({ + baseURL: SPARK_BACKEND_BASE_URL, + timeout: 10000, +}); + +export const setBackendToken = (token: string | null) => { + if (token) { + backend.defaults.headers.common.Authorization = `Bearer ${token}`; + } else { + delete backend.defaults.headers.common.Authorization; + } +}; + +const toCamelReview = (raw: Record): AppReview => ({ + id: Number(raw.id), + rating: Number(raw.rating), + content: String(raw.content || ""), + version: String(raw.version || "unknown"), + packageArch: String(raw.package_arch || "unknown"), + clientArch: String(raw.client_arch || "unknown"), + distro: String(raw.distro || "unknown"), + origin: raw.origin === "spark" ? "spark" : "apm", + category: String(raw.category || ""), + createdAt: String(raw.created_at || ""), + updatedAt: String(raw.updated_at || ""), + userDisplayName: String(raw.user_display_name || ""), + userAvatarUrl: String(raw.user_avatar_url || ""), +}); + +export const exchangeFlarumToken = async (payload: { + flarumUserId: string; + flarumToken: string; +}): Promise => { + const response = await backend.post("/auth/flarum", { + flarum_user_id: payload.flarumUserId, + flarum_token: payload.flarumToken, + }); + const data = response.data; + return { + accessToken: data.access_token, + tokenType: data.token_type, + user: { + id: data.user.id, + flarumUserId: data.user.flarum_user_id, + username: data.user.username, + displayName: data.user.display_name, + avatarUrl: data.user.avatar_url, + }, + }; +}; + +export const fetchRatingSummary = async (appKey: string): Promise => { + const response = await backend.get(`/apps/${encodeURIComponent(appKey)}/rating-summary`); + return { + averageRating: Number(response.data.average_rating || 0), + reviewCount: Number(response.data.review_count || 0), + starCounts: response.data.star_counts || {}, + }; +}; + +export const fetchReviews = async (appKey: string, params: Record): Promise => { + const response = await backend.get(`/apps/${encodeURIComponent(appKey)}/reviews`, { params }); + return (response.data || []).map((item: Record) => toCamelReview(item)); +}; + +export const submitReview = async (appKey: string, payload: { rating: number; content: string; tags: ReviewTags }): Promise => { + const response = await backend.post(`/apps/${encodeURIComponent(appKey)}/reviews`, { + rating: payload.rating, + content: payload.content, + tags: { + origin: payload.tags.origin, + category: payload.tags.category, + pkgname: payload.tags.pkgname, + version: payload.tags.version, + package_arch: payload.tags.packageArch, + client_arch: payload.tags.clientArch, + distro: payload.tags.distro, + }, + }); + return toCamelReview(response.data); +}; + +export const fetchSyncedAppList = async (): Promise => { + const response = await backend.get("/me/app-list"); + if (!response.data) return null; + return { + snapshotName: response.data.snapshot_name, + clientArch: response.data.client_arch, + distro: response.data.distro, + updatedAt: response.data.updated_at, + items: (response.data.items || []).map((item: Record) => ({ + id: Number(item.id), + pkgname: String(item.pkgname || ""), + origin: item.origin === "spark" ? "spark" : "apm", + category: String(item.category || ""), + version: String(item.version || ""), + packageArch: String(item.package_arch || "unknown"), + appName: String(item.app_name || ""), + iconUrl: String(item.icon_url || ""), + })), + }; +}; + +export const uploadSyncedAppList = async (payload: { + clientArch: string; + distro: string; + items: SyncedAppListItem[]; +}): Promise => { + const response = await backend.put("/me/app-list", { + client_arch: payload.clientArch, + distro: payload.distro, + items: payload.items.map((item) => ({ + pkgname: item.pkgname, + origin: item.origin, + category: item.category, + version: item.version, + package_arch: item.packageArch, + app_name: item.appName, + icon_url: item.iconUrl, + })), + }); + return response.data; +}; +``` + +- [ ] **Step 4: Add auth state** + +Create `src/global/authState.ts` with: + +```typescript +import { computed, ref } from "vue"; + +import { setBackendToken } from "@/modules/backendApi"; +import type { AuthSession } from "@/global/typedefinition"; + +const STORAGE_KEY = "spark-store-auth"; + +const readStoredSession = (): AuthSession | null => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as AuthSession; + if (!parsed.accessToken || !parsed.user) return null; + return parsed; + } catch { + return null; + } +}; + +export const authSession = ref(readStoredSession()); +export const currentUser = computed(() => authSession.value?.user ?? null); +export const isLoggedIn = computed(() => Boolean(authSession.value?.accessToken)); + +setBackendToken(authSession.value?.accessToken ?? null); + +export const setAuthSession = (session: AuthSession) => { + authSession.value = session; + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); + setBackendToken(session.accessToken); +}; + +export const logout = () => { + authSession.value = null; + localStorage.removeItem(STORAGE_KEY); + setBackendToken(null); +}; +``` + +- [ ] **Step 5: Run auth state test and verify pass** + +Run: `npm run test -- src/__tests__/unit/authState.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Commit API/auth state** + +Run: + +```bash +git add src/modules/backendApi.ts src/global/authState.ts src/__tests__/unit/authState.test.ts +git commit -m "feat(account): add backend api and auth state" +``` + +Expected: commit succeeds if commits are requested for implementation execution. + +## Task 3: Add Flarum Login Modal And Header Account UI + +**Files:** +- Create: `src/components/LoginModal.vue` +- Modify: `src/components/AppHeader.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/LoginModal.test.ts` + +- [ ] **Step 1: Write failing LoginModal test** + +Create `src/__tests__/unit/LoginModal.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it, vi } from "vitest"; + +import LoginModal from "@/components/LoginModal.vue"; + +describe("LoginModal", () => { + it("emits login credentials", async () => { + const rendered = render(LoginModal, { + props: { show: true, loading: false, error: "" }, + }); + + await fireEvent.update(screen.getByLabelText("论坛账号"), "momen"); + await fireEvent.update(screen.getByLabelText("论坛密码"), "secret"); + await fireEvent.click(screen.getByRole("button", { name: "登录" })); + + expect(rendered.emitted("login")?.[0]?.[0]).toEqual({ identification: "momen", password: "secret" }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/LoginModal.test.ts` + +Expected: FAIL because `LoginModal.vue` does not exist. + +- [ ] **Step 3: Create LoginModal component** + +Create `src/components/LoginModal.vue` with: + +```vue + + + +``` + +- [ ] **Step 4: Run LoginModal test and verify pass** + +Run: `npm run test -- src/__tests__/unit/LoginModal.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Add account UI props to header** + +Modify `src/components/AppHeader.vue` props and emits: + +```typescript +import type { SparkUser } from "@/global/typedefinition"; + +const props = defineProps<{ + searchQuery: string; + activeTab: string; + appsCount: number; + currentUser: SparkUser | null; +}>(); + +const emit = defineEmits<{ + (e: "update-search", query: string): void; + (e: "search-focus"): void; + (e: "open-install-settings"): void; + (e: "open-about"): void; + (e: "toggle-sidebar"): void; + (e: "login"): void; + (e: "logout"): void; +}>(); +``` + +Add a button after the About button: + +```vue + + +``` + +- [ ] **Step 6: Wire login modal in App.vue** + +Modify `src/App.vue` to import `LoginModal`, `currentUser`, `setAuthSession`, `logout`, and `exchangeFlarumToken`. Add state: + +```typescript +const showLoginModal = ref(false); +const loginLoading = ref(false); +const loginError = ref(""); +``` + +Pass `:current-user="currentUser"` to `AppHeader`, add `@login="showLoginModal = true"` and `@logout="logout"`, and mount: + +```vue + +``` + +Add handler: + +```typescript +const handleFlarumLogin = async (payload: { identification: string; password: string }) => { + loginLoading.value = true; + loginError.value = ""; + try { + const response = await axios.post("https://bbs.spark-app.store/api/token", { + identification: payload.identification, + password: payload.password, + }); + const session = await exchangeFlarumToken({ + flarumUserId: String(response.data.userId), + flarumToken: String(response.data.token), + }); + setAuthSession(session); + showLoginModal.value = false; + } catch (error) { + loginError.value = error instanceof Error ? error.message : "登录失败"; + } finally { + loginLoading.value = false; + } +}; +``` + +- [ ] **Step 7: Run targeted tests** + +Run: `npm run test -- src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/authState.test.ts` + +Expected: PASS. + +- [ ] **Step 8: Commit login UI** + +Run: + +```bash +git add src/components/LoginModal.vue src/components/AppHeader.vue src/App.vue src/__tests__/unit/LoginModal.test.ts +git commit -m "feat(account): add Flarum login UI" +``` + +Expected: commit succeeds if commits are requested for implementation execution. + +## Task 4: Add System Info IPC And Immutable Review Tags + +**Files:** +- Modify: `electron/main/index.ts` +- Create: `src/modules/reviewTags.ts` +- Test: `src/__tests__/unit/reviewTags.test.ts` + +- [ ] **Step 1: Write failing review tag tests** + +Create `src/__tests__/unit/reviewTags.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { buildAppKey, buildReviewTags, parsePackageArch } from "@/modules/reviewTags"; +import type { App } from "@/global/typedefinition"; + +const app: App = { + name: "WPS", + pkgname: "wps", + version: "1.0.0", + filename: "wps_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: "", + tags: "", + img_urls: [], + icons: "", + category: "office", + origin: "apm", + currentStatus: "installed", +}; + +describe("reviewTags", () => { + it("builds stable app keys", () => { + expect(buildAppKey(app, "amd64")).toBe("apm:amd64-apm:office:wps"); + }); + + it("parses package architecture from deb filename", () => { + expect(parsePackageArch("wps_1.0.0_amd64.deb")).toBe("amd64"); + }); + + it("builds immutable review tags", () => { + expect(buildReviewTags(app, { clientArch: "amd64", distro: "deepin 25" })).toEqual({ + origin: "apm", + category: "office", + pkgname: "wps", + version: "1.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/reviewTags.test.ts` + +Expected: FAIL because `reviewTags` module does not exist. + +- [ ] **Step 3: Add review tag module** + +Create `src/modules/reviewTags.ts` with: + +```typescript +import type { App, ReviewTags } from "@/global/typedefinition"; + +export const parsePackageArch = (filename: string | undefined): string => { + if (!filename) return "unknown"; + const match = filename.match(/_([^_]+)\.(?:deb|rpm|appimage|tar\.gz)$/i); + return match?.[1] || "unknown"; +}; + +export const buildStoreArch = (origin: "spark" | "apm", clientArch: string): string => { + return origin === "spark" ? `${clientArch}-store` : `${clientArch}-apm`; +}; + +export const buildAppKey = (app: App, clientArch: string): string => { + return `${app.origin}:${buildStoreArch(app.origin, clientArch)}:${app.category}:${app.pkgname}`; +}; + +export const buildReviewTags = ( + app: App, + system: { clientArch: string; distro: string }, +): ReviewTags => ({ + origin: app.origin, + category: app.category || "unknown", + pkgname: app.pkgname, + version: app.version || "unknown", + packageArch: app.arch || parsePackageArch(app.filename), + clientArch: system.clientArch || "unknown", + distro: system.distro || "unknown", +}); +``` + +- [ ] **Step 4: Add system info IPC** + +Modify `electron/main/index.ts` by adding near `get-app-version`: + +```typescript +const getSystemInfo = (): { distro: string } => { + try { + const raw = fs.readFileSync("/etc/os-release", "utf8"); + const values = Object.fromEntries( + raw + .split("\n") + .filter((line) => line.includes("=")) + .map((line) => { + const [key, ...rest] = line.split("="); + return [key, rest.join("=").replace(/^"|"$/g, "")]; + }), + ); + const name = values.PRETTY_NAME || values.NAME || values.ID || "unknown"; + return { distro: name }; + } catch { + return { distro: "unknown" }; + } +}; + +ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo()); +``` + +- [ ] **Step 5: Run review tag tests and verify pass** + +Run: `npm run test -- src/__tests__/unit/reviewTags.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Commit tags/system info** + +Run: + +```bash +git add electron/main/index.ts src/modules/reviewTags.ts src/__tests__/unit/reviewTags.test.ts +git commit -m "feat(reviews): add immutable review tags" +``` + +Expected: commit succeeds if commits are requested for implementation execution. + +## Task 5: Add ReviewsPanel To App Detail Modal + +**Files:** +- Create: `src/components/ReviewsPanel.vue` +- Modify: `src/components/AppDetailModal.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/ReviewsPanel.test.ts` + +- [ ] **Step 1: Write failing ReviewsPanel test** + +Create `src/__tests__/unit/ReviewsPanel.test.ts` with: + +```typescript +import { render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import ReviewsPanel from "@/components/ReviewsPanel.vue"; +import type { ReviewTags } from "@/global/typedefinition"; + +const tags: ReviewTags = { + origin: "apm", + category: "office", + pkgname: "wps", + version: "1.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", +}; + +describe("ReviewsPanel", () => { + it("shows login prompt for anonymous users and read-only tags", () => { + render(ReviewsPanel, { + props: { + appKey: "apm:amd64-apm:office:wps", + tags, + loggedIn: false, + }, + }); + + expect(screen.getByText("登录后发表评论")).toBeTruthy(); + expect(screen.getByText("1.0.0")).toBeTruthy(); + expect(screen.getByText("amd64")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/ReviewsPanel.test.ts` + +Expected: FAIL because `ReviewsPanel.vue` does not exist. + +- [ ] **Step 3: Create ReviewsPanel component** + +Create `src/components/ReviewsPanel.vue` with: + +```vue + + + +``` + +- [ ] **Step 4: Run ReviewsPanel test and verify pass** + +Run: `npm run test -- src/__tests__/unit/ReviewsPanel.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Mount ReviewsPanel in AppDetailModal** + +Modify `src/components/AppDetailModal.vue`: + +1. Import `ReviewsPanel`. +2. Add props: + +```typescript +reviewAppKey: string; +reviewTags: ReviewTags | null; +loggedIn: boolean; +``` + +3. Add emit: + +```typescript +(e: "request-login"): void; +``` + +4. Add below the screenshot block: + +```vue + +``` + +- [ ] **Step 6: Build review props in App.vue** + +Modify `src/App.vue`: + +1. Import `buildAppKey` and `buildReviewTags`. +2. Add refs: + +```typescript +const systemInfo = ref({ distro: "unknown" }); +``` + +3. On mount, call: + +```typescript +systemInfo.value = await window.ipcRenderer.invoke("get-system-info"); +``` + +4. Add computed values: + +```typescript +const currentDisplayAppForReview = computed(() => { + const app = currentApp.value; + if (!app) return null; + if (!app.isMerged) return app; + return app.viewingOrigin === "spark" ? app.sparkApp || app : app.apmApp || app; +}); + +const currentReviewAppKey = computed(() => { + const app = currentDisplayAppForReview.value; + return app ? buildAppKey(app, window.apm_store.arch || "amd64") : ""; +}); + +const currentReviewTags = computed(() => { + const app = currentDisplayAppForReview.value; + return app + ? buildReviewTags(app, { + clientArch: window.apm_store.arch || "amd64", + distro: systemInfo.value.distro, + }) + : null; +}); +``` + +5. Pass these props to `AppDetailModal` and wire `@request-login="showLoginModal = true"`. + +- [ ] **Step 7: Run targeted tests** + +Run: `npm run test -- src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/reviewTags.test.ts` + +Expected: PASS. + +- [ ] **Step 8: Commit review panel** + +Run: + +```bash +git add src/components/ReviewsPanel.vue src/components/AppDetailModal.vue src/App.vue src/__tests__/unit/ReviewsPanel.test.ts +git commit -m "feat(reviews): show app reviews in details" +``` + +Expected: commit succeeds if commits are requested for implementation execution. + +## Task 6: Add App-List Sync Filtering + +**Files:** +- Create: `src/modules/appListSync.ts` +- Test: `src/__tests__/unit/appListSync.test.ts` + +- [ ] **Step 1: Write failing sync filtering tests** + +Create `src/__tests__/unit/appListSync.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { buildSyncItems } from "@/modules/appListSync"; +import type { App } from "@/global/typedefinition"; + +const baseApp: App = { + name: "Spark Notes", + pkgname: "spark-notes", + version: "1.0.0", + filename: "spark-notes_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: "", + tags: "", + img_urls: [], + icons: "", + category: "office", + origin: "spark", + currentStatus: "installed", +}; + +describe("appListSync", () => { + it("syncs only store-recognized non-dependency apps", () => { + const items = buildSyncItems([ + baseApp, + { ...baseApp, pkgname: "unknown", category: "unknown" }, + { ...baseApp, pkgname: "dep", isDependency: true }, + ]); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ pkgname: "spark-notes", origin: "spark", category: "office" }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/appListSync.test.ts` + +Expected: FAIL because `appListSync` module does not exist. + +- [ ] **Step 3: Add sync filtering module** + +Create `src/modules/appListSync.ts` with: + +```typescript +import type { App, SyncedAppListItem } from "@/global/typedefinition"; +import { parsePackageArch } from "@/modules/reviewTags"; + +export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => { + return apps + .filter((app) => app.currentStatus === "installed") + .filter((app) => app.category !== "unknown") + .filter((app) => !app.isDependency) + .filter((app) => Boolean(app.pkgname && app.origin)) + .map((app) => ({ + pkgname: app.pkgname, + origin: app.origin, + category: app.category, + version: app.version || "", + packageArch: app.arch || parsePackageArch(app.filename), + appName: app.name || app.pkgname, + iconUrl: app.icons || "", + })); +}; + +export const isCloudItemInstalled = ( + item: SyncedAppListItem, + installedApps: App[], +): boolean => { + return installedApps.some((app) => app.pkgname === item.pkgname && app.origin === item.origin && app.currentStatus === "installed"); +}; +``` + +- [ ] **Step 4: Run sync filtering tests and verify pass** + +Run: `npm run test -- src/__tests__/unit/appListSync.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit sync helpers** + +Run: + +```bash +git add src/modules/appListSync.ts src/__tests__/unit/appListSync.test.ts +git commit -m "feat(sync): add app list filtering" +``` + +Expected: commit succeeds if commits are requested for implementation execution. + +## Task 7: Add Sync And Restore UI + +**Files:** +- Create: `src/components/AppListRestoreModal.vue` +- Modify: `src/components/InstalledAppsModal.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/AppListRestoreModal.test.ts` +- Test: `src/__tests__/unit/InstalledAppsModal.test.ts` + +- [ ] **Step 1: Write failing restore modal test** + +Create `src/__tests__/unit/AppListRestoreModal.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import AppListRestoreModal from "@/components/AppListRestoreModal.vue"; + +describe("AppListRestoreModal", () => { + it("emits selected installable items", async () => { + const rendered = render(AppListRestoreModal, { + props: { + show: true, + loading: false, + error: "", + items: [ + { + pkgname: "spark-notes", + origin: "spark", + category: "office", + version: "1.0.0", + packageArch: "amd64", + appName: "Spark Notes", + iconUrl: "", + }, + ], + installedKeys: [], + }, + }); + + await fireEvent.click(screen.getByLabelText("选择 Spark Notes")); + await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" })); + + expect(rendered.emitted("install-selected")?.[0]?.[0]).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/AppListRestoreModal.test.ts` + +Expected: FAIL because `AppListRestoreModal.vue` does not exist. + +- [ ] **Step 3: Create restore modal** + +Create `src/components/AppListRestoreModal.vue` with: + +1. Props `show`, `loading`, `error`, `items: SyncedAppListItem[]`, `installedKeys: string[]`. +2. Emits `close` and `install-selected`. +3. Local selected map keyed by `${origin}:${pkgname}`. +4. Checkbox per item with accessible label `选择 ${item.appName || item.pkgname}`. +5. Disable checkbox when installed key is present. +6. Button text `加入安装队列`. + +- [ ] **Step 4: Run restore modal test and verify pass** + +Run: `npm run test -- src/__tests__/unit/AppListRestoreModal.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Add sync buttons to InstalledAppsModal** + +Modify `src/components/InstalledAppsModal.vue`: + +1. Add props: + +```typescript +loggedIn: boolean; +syncing: boolean; +``` + +2. Add emits: + +```typescript +(e: "sync-to-account"): void; +(e: "restore-from-account"): void; +(e: "request-login"): void; +``` + +3. Add header buttons before Refresh: + +```vue + + +``` + +4. Update existing `InstalledAppsModal.test.ts` props to pass `loggedIn: false` and `syncing: false`. + +- [ ] **Step 6: Wire sync/restore in App.vue** + +Modify `src/App.vue`: + +1. Import `AppListRestoreModal`, `buildSyncItems`, `fetchSyncedAppList`, and `uploadSyncedAppList`. +2. Add state: + +```typescript +const syncLoading = ref(false); +const restoreLoading = ref(false); +const restoreError = ref(""); +const showRestoreModal = ref(false); +const restoreItems = ref([]); +``` + +3. Pass `:logged-in="isLoggedIn"` and `:syncing="syncLoading"` to `InstalledAppsModal`. +4. Add handlers: + +```typescript +const syncInstalledAppsToAccount = async () => { + syncLoading.value = true; + try { + const items = buildSyncItems(installedApps.value); + await uploadSyncedAppList({ + clientArch: window.apm_store.arch || "amd64", + distro: systemInfo.value.distro, + items, + }); + } finally { + syncLoading.value = false; + } +}; + +const openRestoreFromAccount = async () => { + restoreLoading.value = true; + restoreError.value = ""; + showRestoreModal.value = true; + try { + const list = await fetchSyncedAppList(); + restoreItems.value = list?.items || []; + } catch (error) { + restoreError.value = error instanceof Error ? error.message : "读取云端列表失败"; + } finally { + restoreLoading.value = false; + } +}; +``` + +5. Mount `AppListRestoreModal` and on `install-selected`, map selected cloud items to catalog `App` entries from `apps.value` by `pkgname`, `origin`, and `category`, then call existing `handleInstall(app)` for each. + +- [ ] **Step 7: Run targeted tests** + +Run: `npm run test -- src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts src/__tests__/unit/appListSync.test.ts` + +Expected: PASS. + +- [ ] **Step 8: Commit sync UI** + +Run: + +```bash +git add src/components/AppListRestoreModal.vue src/components/InstalledAppsModal.vue src/App.vue src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts +git commit -m "feat(sync): add account app restore UI" +``` + +Expected: commit succeeds if commits are requested for implementation execution. + +## Task 8: Final Client Verification + +**Files:** +- Verify: no planned file edits in this task. + +- [ ] **Step 1: Run account-related unit tests** + +Run: + +```bash +npm run test -- src/__tests__/unit/accountTypes.test.ts src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/reviewTags.test.ts src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts +``` + +Expected: all listed tests PASS. + +- [ ] **Step 2: Run full unit test suite** + +Run: `npm run test` + +Expected: all unit tests PASS. + +- [ ] **Step 3: Run lint** + +Run: `npm run lint` + +Expected: exits 0 with no lint errors. + +- [ ] **Step 4: Run Vite build** + +Run: `npm run build:vite` + +Expected: exits 0 and produces renderer/main build artifacts. + +- [ ] **Step 5: Check working tree** + +Run: `git status --short` + +Expected: only intentional changes remain. `.superpowers/` must not appear because `.gitignore` ignores it. + +## Self-Review Checklist + +Spec coverage: + +- Login and avatar/name display: Tasks 2 and 3. +- Direct client-to-Flarum token login: Task 3. +- Backend JWT storage and use: Task 2. +- Detail-page review panel: Task 5. +- Immutable automatic tags: Task 4. +- Review filtering foundation: Task 5. +- Store-recognized app-list sync: Tasks 6 and 7. +- Restore through existing install queue: Task 7. + +Placeholder scan: + +- The plan has no deferred implementation sections and no placeholder tasks. + +Type consistency: + +- Backend snake_case payloads are mapped in `backendApi.ts`. +- UI state uses camelCase `SparkUser`, `ReviewTags`, and `SyncedAppListItem`. +- `window.apm_store.arch` is treated as bare architecture such as `amd64`. diff --git a/docs/superpowers/plans/2026-05-19-app-detail-fixed-sidebar-scroll.md b/docs/superpowers/plans/2026-05-19-app-detail-fixed-sidebar-scroll.md new file mode 100644 index 00000000..297a872f --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-app-detail-fixed-sidebar-scroll.md @@ -0,0 +1,150 @@ +# App Detail Fixed Sidebar Scroll Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the app detail modal's left action/meta column fixed on desktop while the right detail/review column scrolls independently. + +**Architecture:** Reuse the existing `AppDetailModal.vue` popup and change only its internal layout classes/markers. The modal panel becomes a bounded, non-scrolling shell on desktop, while the right column becomes the desktop scroll container; mobile keeps the single-column scroll behavior. + +**Tech Stack:** Vue 3 SFC, Tailwind CSS utilities, Vitest, Testing Library Vue. + +--- + +## File Structure + +- Modify: `src/components/AppDetailModal.vue` - adjust modal shell, left column, right scroll column, and scroll reset target. +- Modify: `src/__tests__/unit/AppDetailModal.test.ts` - assert modal shell and scroll column contract. + +## Task 1: Add Layout Contract Test + +**Files:** +- Modify: `src/__tests__/unit/AppDetailModal.test.ts` + +- [ ] **Step 1: Add assertions to the popup modal test** + +In `src/__tests__/unit/AppDetailModal.test.ts`, update `renders detail content inside a popup-style modal overlay` so the body after the `.modal-panel` assertion is: + +```ts + const panel = overlay?.querySelector(".modal-panel"); + expect(panel).toBeTruthy(); + expect(panel?.className).toContain("overflow-hidden"); + expect(panel?.className).toContain("lg:max-h-[85vh]"); + expect(panel?.querySelector('[data-testid="detail-fixed-sidebar"]')).toBeTruthy(); + expect(panel?.querySelector('[data-testid="detail-scroll-content"]')).toBeTruthy(); + expect( + panel?.querySelector('[data-testid="detail-scroll-content"]')?.className, + ).toContain("lg:overflow-y-auto"); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- --run src/__tests__/unit/AppDetailModal.test.ts` + +Expected: FAIL because `data-testid="detail-fixed-sidebar"`, `data-testid="detail-scroll-content"`, and the new modal classes are not present. + +## Task 2: Implement Fixed Sidebar Layout + +**Files:** +- Modify: `src/components/AppDetailModal.vue` +- Modify: `src/__tests__/unit/AppDetailModal.test.ts` + +- [ ] **Step 1: Update modal shell classes** + +In `src/components/AppDetailModal.vue`, change the `.modal-panel` class from: + +```vue +class="modal-panel relative w-full max-w-5xl max-h-[85vh] overflow-y-auto overscroll-contain scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 px-6 pb-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900" +``` + +to: + +```vue +class="modal-panel relative flex w-full max-w-5xl max-h-[85vh] overflow-y-auto overscroll-contain scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 px-6 pb-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900 lg:max-h-[85vh] lg:overflow-hidden lg:pb-0" +``` + +- [ ] **Step 2: Move the return button into the fixed sidebar** + +In `src/components/AppDetailModal.vue`, replace the top-level return button and main layout start with: + +```vue + +
+ +
+ +``` + +Remove the old sticky top-level return button and the old left column opening: + +```vue + + + + +
+ +
+``` + +- [ ] **Step 3: Make the right column the desktop scroll container** + +In `src/components/AppDetailModal.vue`, change the right column opening from: + +```vue +
+``` + +to: + +```vue +
+``` + +- [ ] **Step 4: Update scroll reset target if needed** + +In `src/App.vue`, keep the existing modal scroll reset selector if it still points at `.modal-panel`. If the right column needs reset instead, change the query to: + +```ts +const modal = document.querySelector( + '[data-app-modal="detail"] [data-testid="detail-scroll-content"]', +); +``` + +Use `modal.scrollTop = 0` as it does today. + +- [ ] **Step 5: Run focused test** + +Run: `npm run test -- --run src/__tests__/unit/AppDetailModal.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Run build verification** + +Run: `npm run build:vite` + +Expected: PASS. + +- [ ] **Step 7: Commit implementation** + +Run: + +```bash +git add src/components/AppDetailModal.vue src/App.vue src/__tests__/unit/AppDetailModal.test.ts +git commit -m "fix(ui): pin detail modal sidebar" +``` + +Expected: commit succeeds. diff --git a/docs/superpowers/plans/2026-05-19-review-avatar-display.md b/docs/superpowers/plans/2026-05-19-review-avatar-display.md new file mode 100644 index 00000000..6866de79 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-review-avatar-display.md @@ -0,0 +1,141 @@ +# Review Avatar Display Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show cached backend user avatar URLs beside comments in the app review list. + +**Architecture:** Use the existing `AppReview.userAvatarUrl` field returned by the backend. `ReviewsPanel.vue` renders an avatar image when present and a stable fallback when missing or failed; tests cover the rendered avatar contract. + +**Tech Stack:** Vue 3 SFC, Tailwind CSS utilities, Vitest, Testing Library Vue. + +--- + +## File Structure + +- Modify: `src/components/ReviewsPanel.vue` - render reviewer avatar/fallback in each review card and handle broken avatar images. +- Modify: `src/__tests__/unit/ReviewsPanel.test.ts` - add test coverage for avatar display. + +## Task 1: Add Avatar Rendering Test + +**Files:** +- Modify: `src/__tests__/unit/ReviewsPanel.test.ts` + +- [ ] **Step 1: Add review with avatar test** + +Append this test inside `describe("ReviewsPanel", () => { ... })` before the final closing brace: + +```ts + it("shows reviewer avatars when available", async () => { + vi.mocked(fetchRatingSummary).mockResolvedValue({ + averageRating: 5, + reviewCount: 1, + starCounts: { 5: 1 }, + }); + vi.mocked(fetchReviews).mockResolvedValue([ + { + id: 3, + rating: 5, + content: "头像正常显示", + version: tags.version, + packageArch: tags.packageArch, + clientArch: tags.clientArch, + distro: tags.distro, + origin: tags.origin, + category: tags.category, + createdAt: "2026-05-19T00:00:00Z", + updatedAt: "2026-05-19T00:00:00Z", + userDisplayName: "Avatar User", + userAvatarUrl: "https://bbs.spark-app.store/avatar.png", + }, + ]); + + render(ReviewsPanel, { + props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true }, + }); + + const avatar = await screen.findByAltText("Avatar User 的头像"); + expect(avatar).toHaveAttribute("src", "https://bbs.spark-app.store/avatar.png"); + }); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- --run src/__tests__/unit/ReviewsPanel.test.ts` + +Expected: FAIL because no avatar image with alt text is rendered. + +## Task 2: Render Review Avatars + +**Files:** +- Modify: `src/components/ReviewsPanel.vue` +- Modify: `src/__tests__/unit/ReviewsPanel.test.ts` + +- [ ] **Step 1: Update review article layout** + +In `src/components/ReviewsPanel.vue`, replace the review `
` body with this structure: + +```vue +
+ + +
+
+ + {{ review.userDisplayName || "星火用户" }} + + {{ review.rating }} 星 +
+

+ {{ review.content || "暂无评论内容" }} +

+
+
+``` + +- [ ] **Step 2: Add avatar error handler** + +In the ` + + diff --git a/src/components/AppCard.vue b/src/components/AppCard.vue index 347127fd..e266ea8d 100644 --- a/src/components/AppCard.vue +++ b/src/components/AppCard.vue @@ -21,16 +21,13 @@ > {{ app.name || "" }}
-
- SPARK/APM - +
@@ -261,7 +270,10 @@
-
+

暂无应用截图

+ + +
+

+ + 应用评价 +

+

+ 登录星火账号后可查看评价并发表评论。 +

+ +
+
+

+ + 应用评价 +

+

+ 安装应用后可发表评论。 +

+
@@ -439,12 +496,14 @@ diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue index d5dcad1d..753b1238 100644 --- a/src/components/AppHeader.vue +++ b/src/components/AppHeader.vue @@ -51,13 +51,6 @@
-
- -
@@ -66,7 +59,7 @@ import { ref, watch } from "vue"; const props = defineProps<{ searchQuery: string; - activeCategory: string; + activeTab: string; appsCount: number; }>(); const emit = defineEmits<{ diff --git a/src/components/AppListRestoreModal.vue b/src/components/AppListRestoreModal.vue new file mode 100644 index 00000000..51aad703 --- /dev/null +++ b/src/components/AppListRestoreModal.vue @@ -0,0 +1,195 @@ + + + diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue index 124a373b..d66ad4d3 100644 --- a/src/components/AppSidebar.vue +++ b/src/components/AppSidebar.vue @@ -1,21 +1,44 @@ + + diff --git a/src/components/CategoryBar.vue b/src/components/CategoryBar.vue new file mode 100644 index 00000000..613ccc30 --- /dev/null +++ b/src/components/CategoryBar.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/src/components/DownloadQueue.vue b/src/components/DownloadQueue.vue index 85c7b994..c93b35ae 100644 --- a/src/components/DownloadQueue.vue +++ b/src/components/DownloadQueue.vue @@ -30,7 +30,7 @@ + + +
+ +
+ +
加载中...
+
{{ error }}
+
+
+ 当前收藏夹暂无应用。 +
+
+ + + + {{ resolved.reason }} + +
+
+ +
+ + + {{ installableSelectionMessage }} + + + +
+ + + + diff --git a/src/components/FavoriteFolderSelector.vue b/src/components/FavoriteFolderSelector.vue new file mode 100644 index 00000000..9b6965a9 --- /dev/null +++ b/src/components/FavoriteFolderSelector.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/components/HomeView.vue b/src/components/HomeView.vue index ab654f7a..5050dccc 100644 --- a/src/components/HomeView.vue +++ b/src/components/HomeView.vue @@ -72,7 +72,6 @@ class="group block overflow-hidden rounded-xl transition-transform duration-300 hover:scale-[1.02]" :title="link.more as string" > -
@@ -88,7 +87,6 @@ imageLoaded[link.url + link.name], }" /> -
-
-

+

{{ section.title }}

diff --git a/src/components/InstalledAppsModal.vue b/src/components/InstalledAppsModal.vue index f4b8f763..7c349189 100644 --- a/src/components/InstalledAppsModal.vue +++ b/src/components/InstalledAppsModal.vue @@ -28,6 +28,23 @@

+ +
+
+ {{ syncMessage }} +
(); -defineEmits<{ +const emit = defineEmits<{ (e: "close"): void; (e: "refresh"): void; (e: "uninstall", app: App): void; (e: "switch-origin", origin: "apm" | "spark"): void; (e: "open-app", app: App): void; (e: "open-detail", app: App): void; + (e: "sync-to-account"): void; + (e: "restore-from-account"): void; + (e: "request-login"): void; }>(); +const handleSyncClick = () => { + if (props.loggedIn) { + emit("sync-to-account"); + return; + } + + emit("request-login"); +}; + +const handleRestoreClick = () => { + if (props.loggedIn) { + emit("restore-from-account"); + return; + } + + emit("request-login"); +}; + const onOverlayWheel = (e: WheelEvent) => { const target = e.target as HTMLElement; if (target.closest(".overflow-y-auto, .overflow-auto")) return; diff --git a/src/components/LoginModal.vue b/src/components/LoginModal.vue new file mode 100644 index 00000000..266d3316 --- /dev/null +++ b/src/components/LoginModal.vue @@ -0,0 +1,115 @@ + + + diff --git a/src/components/LoginPromptModal.vue b/src/components/LoginPromptModal.vue new file mode 100644 index 00000000..bc656ac2 --- /dev/null +++ b/src/components/LoginPromptModal.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/components/ReviewUserProfileModal.vue b/src/components/ReviewUserProfileModal.vue new file mode 100644 index 00000000..569f0a9e --- /dev/null +++ b/src/components/ReviewUserProfileModal.vue @@ -0,0 +1,140 @@ + + + diff --git a/src/components/ReviewsPanel.vue b/src/components/ReviewsPanel.vue new file mode 100644 index 00000000..0147c493 --- /dev/null +++ b/src/components/ReviewsPanel.vue @@ -0,0 +1,695 @@ + + + diff --git a/src/components/UserManagementModal.vue b/src/components/UserManagementModal.vue new file mode 100644 index 00000000..bf640dbd --- /dev/null +++ b/src/components/UserManagementModal.vue @@ -0,0 +1,150 @@ + + + diff --git a/src/components/UserManagementView.vue b/src/components/UserManagementView.vue new file mode 100644 index 00000000..f5405f30 --- /dev/null +++ b/src/components/UserManagementView.vue @@ -0,0 +1,201 @@ + + + diff --git a/src/components/WindowTitleBar.vue b/src/components/WindowTitleBar.vue new file mode 100644 index 00000000..026a6ddd --- /dev/null +++ b/src/components/WindowTitleBar.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/global/accountSyncState.ts b/src/global/accountSyncState.ts new file mode 100644 index 00000000..7b7d2958 --- /dev/null +++ b/src/global/accountSyncState.ts @@ -0,0 +1,39 @@ +import { ref, watch } from "vue"; + +const INSTALLED_SYNC_STORAGE_KEY = "spark-store-installed-sync-enabled"; +let activeSyncUserId: number | null = null; + +const syncStorageKey = (userId: number | null): string => + userId === null + ? INSTALLED_SYNC_STORAGE_KEY + : `${INSTALLED_SYNC_STORAGE_KEY}:${userId}`; + +const readSyncEnabled = (userId: number | null): boolean | null => { + const savedValue = localStorage.getItem(syncStorageKey(userId)); + if (savedValue === "true") return true; + if (savedValue === "false") return false; + return null; +}; + +export const installedSyncEnabled = ref( + readSyncEnabled(activeSyncUserId), +); + +export const loadInstalledSyncPreference = (userId: number | null): void => { + activeSyncUserId = userId; + installedSyncEnabled.value = readSyncEnabled(userId); +}; + +export const setInstalledSyncEnabled = (enabled: boolean): void => { + installedSyncEnabled.value = enabled; + localStorage.setItem(syncStorageKey(activeSyncUserId), String(enabled)); +}; + +watch(installedSyncEnabled, (enabled) => { + if (enabled === null) { + localStorage.removeItem(syncStorageKey(activeSyncUserId)); + return; + } + + localStorage.setItem(syncStorageKey(activeSyncUserId), String(enabled)); +}); diff --git a/src/global/authState.ts b/src/global/authState.ts new file mode 100644 index 00000000..49637e82 --- /dev/null +++ b/src/global/authState.ts @@ -0,0 +1,63 @@ +import { computed, ref } from "vue"; + +import { setBackendToken } from "@/modules/backendApi"; +import type { AuthSession, SparkUser } from "./typedefinition"; + +const AUTH_STORAGE_KEY = "spark-store-auth"; + +const isSparkUser = (value: unknown): value is SparkUser => { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const user = value as Record; + return ( + typeof user.id === "number" && + typeof user.flarumUserId === "string" && + typeof user.username === "string" && + typeof user.displayName === "string" && + typeof user.avatarUrl === "string" && + (user.coverUrl === undefined || typeof user.coverUrl === "string") && + typeof user.forumLevel === "string" && + Array.isArray(user.forumGroups) && + user.forumGroups.every((group) => typeof group === "string") + ); +}; + +const isAuthSession = (value: unknown): value is AuthSession => { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const session = value as Record; + return ( + typeof session.accessToken === "string" && + session.accessToken.length > 0 && + session.tokenType === "bearer" && + isSparkUser(session.user) + ); +}; + +const loadStoredSession = (): AuthSession | null => { + const raw = localStorage.getItem(AUTH_STORAGE_KEY); + if (!raw) return null; + + try { + const parsed: unknown = JSON.parse(raw); + return isAuthSession(parsed) ? parsed : null; + } catch { + return null; + } +}; + +export const authSession = ref(loadStoredSession()); +export const currentUser = computed(() => authSession.value?.user ?? null); +export const isLoggedIn = computed(() => authSession.value !== null); + +setBackendToken(authSession.value?.accessToken ?? null); + +export const setAuthSession = (session: AuthSession): void => { + authSession.value = session; + localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session)); + setBackendToken(session.accessToken); +}; + +export const logout = (): void => { + authSession.value = null; + localStorage.removeItem(AUTH_STORAGE_KEY); + setBackendToken(null); +}; diff --git a/src/global/storeConfig.ts b/src/global/storeConfig.ts index 8113a59f..5d8b7134 100644 --- a/src/global/storeConfig.ts +++ b/src/global/storeConfig.ts @@ -7,6 +7,20 @@ export const APM_STORE_BASE_URL: string = export const APM_STORE_STATS_BASE_URL: string = import.meta.env.VITE_APM_STORE_STATS_BASE_URL || ""; +export const DEFAULT_SPARK_BACKEND_BASE_URL = "http://127.0.0.1:8000"; + +export const SPARK_BACKEND_BASE_URL: string = + import.meta.env.VITE_SPARK_BACKEND_BASE_URL || DEFAULT_SPARK_BACKEND_BASE_URL; + +export const SPARK_ACCOUNT_CENTER_URL: string = + import.meta.env.VITE_SPARK_ACCOUNT_CENTER_URL || + "https://account.spark-app.store/account"; + +export const FLARUM_BASE_URL = "https://bbs.spark-app.store"; +export const FLARUM_REGISTER_URL = `${FLARUM_BASE_URL}/register`; +export const FLARUM_PROFILE_URL = `${FLARUM_BASE_URL}/u`; +export const FLARUM_SETTINGS_URL = `${FLARUM_BASE_URL}/settings`; + // 下面的变量用于存储当前应用的信息,其实用在多个组件中 export const currentApp = ref(null); export const currentAppSparkInstalled = ref(false); diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index 6146cd58..d47a2718 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -241,3 +241,171 @@ export interface HomeList { title: string; apps: App[]; } + +export interface SidebarEntry { + id: string; + name: string; + icon?: string; + type?: "category" | "search" | "link"; + value?: string; +} + +export interface SparkUser { + id: number; + flarumUserId: string; + username: string; + displayName: string; + avatarUrl: string; + coverUrl?: string; + forumLevel: string; + forumGroups: string[]; +} + +export interface ReviewUserProfile { + displayName: string; + username?: string; + avatarUrl?: string; + coverUrl?: string; + forumGroups?: string[]; +} + +export interface AuthSession { + accessToken: string; + tokenType: "bearer"; + user: SparkUser; +} + +export interface FlarumLoginPayload { + identification: string; + password: string; +} + +export interface ReviewTags { + origin: "spark" | "apm"; + category: string; + pkgname: string; + version: string; + packageArch: string; + clientArch: string; + distro: string; +} + +export interface RatingSummary { + averageRating: number; + reviewCount: number; + starCounts: Record; +} + +export interface AppReviewReply { + id: number; + reviewId: number; + parentId: number | null; + content: string; + createdAt: string; + updatedAt: string; + userDisplayName: string; + userAvatarUrl: string; + likeCount: number; + likedByCurrentUser: boolean; + canDelete: boolean; + isAuthor: boolean; + isDeleted: boolean; + replies: AppReviewReply[]; +} + +export interface AppReview { + id: number; + userId?: number; + rating: number; + content: string; + version: string; + packageArch: string; + clientArch: string; + distro: string; + origin: "spark" | "apm"; + category: string; + createdAt: string; + updatedAt: string; + userDisplayName: string; + userAvatarUrl: string; + likeCount?: number; + likedByCurrentUser?: boolean; + canDelete?: boolean; + isAuthor?: boolean; + isDeleted?: boolean; + replies?: AppReviewReply[]; +} + +export interface FavoriteFolder { + id: number; + name: string; + itemCount: number; + createdAt: string; + updatedAt: string; +} + +export interface FavoriteItem { + id: number; + appKey: string; + pkgname: string; + name: string; + category: string; + iconUrl: string; + createdAt: string; +} + +export type FavoriteAvailabilityStatus = + | "installable" + | "installed" + | "platform-unavailable" + | "arch-unavailable" + | "downlisted"; + +export interface ResolvedFavoriteItem { + item: FavoriteItem; + status: FavoriteAvailabilityStatus; + reason: string; + selectedApp: App | null; +} + +export interface DownloadedAppRecord { + id: number; + appKey: string; + pkgname: string; + name: string; + category: string; + selectedOrigin: "spark" | "apm"; + version: string; + packageArch: string; + downloadedAt: string; +} + +export interface DownloadedAppList { + items: DownloadedAppRecord[]; + total: number; + page: number; + pageSize: number; +} + +export interface SyncedAppListItem { + id?: number; + pkgname: string; + origin: "spark" | "apm"; + category: string; + version: string; + packageArch: string; + appName: string; + iconUrl: string; +} + +export interface SyncedAppList { + snapshotName: string; + clientArch: string; + distro: string; + updatedAt: string; + items: SyncedAppListItem[]; +} + +export interface SystemInfo { + distro: string; +} diff --git a/src/modules/accountCenterUrl.ts b/src/modules/accountCenterUrl.ts new file mode 100644 index 00000000..2f599ced --- /dev/null +++ b/src/modules/accountCenterUrl.ts @@ -0,0 +1,26 @@ +export const FALLBACK_ACCOUNT_CENTER_URL = + "https://account.spark-app.store/account"; + +export const buildAccountFrameUrl = ( + baseUrl: string, + username: string, +): string => { + let url: URL; + + try { + url = new URL(baseUrl); + } catch { + url = new URL(FALLBACK_ACCOUNT_CENTER_URL); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + url = new URL(FALLBACK_ACCOUNT_CENTER_URL); + } + + const allowedParams = new URLSearchParams(); + allowedParams.set("view", "management"); + allowedParams.set("user", username); + url.search = allowedParams.toString(); + + return url.toString(); +}; diff --git a/src/modules/appIdentity.ts b/src/modules/appIdentity.ts new file mode 100644 index 00000000..7cf59e43 --- /dev/null +++ b/src/modules/appIdentity.ts @@ -0,0 +1,45 @@ +import type { App, ReviewTags } from "@/global/typedefinition"; + +export const parsePackageArch = (filename: string): string => { + const match = filename.match(/_([^_]+)\.deb$/); + return match?.[1] || "unknown"; +}; + +export const buildStoreArch = ( + origin: "spark" | "apm", + clientArch: string, +): string => { + const rawArch = clientArch.replace(/-(store|apm)$/, ""); + return `${rawArch}-${origin === "spark" ? "store" : "apm"}`; +}; + +export const buildFavoriteAppKey = (app: App): string => { + return `app:${app.category || "unknown"}:${app.pkgname}`; +}; + +export const buildReviewAppKey = (app: App, clientArch: string): string => { + return `${app.origin}:${buildStoreArch(app.origin, clientArch)}:${app.category || "unknown"}:${app.pkgname}`; +}; + +export const getDisplayApp = (app: App | null): App | null => { + if (!app) return null; + if (!app.isMerged) return app; + if (app.viewingOrigin === "spark") return app.sparkApp ?? app.apmApp ?? app; + if (app.viewingOrigin === "apm") return app.apmApp ?? app.sparkApp ?? app; + return app.sparkApp ?? app.apmApp ?? app; +}; + +export const buildReviewTags = ( + app: App, + options: { clientArch: string; distro: string }, +): ReviewTags => { + return { + origin: app.origin, + category: app.category || "unknown", + pkgname: app.pkgname, + version: app.version, + packageArch: parsePackageArch(app.filename), + clientArch: options.clientArch, + distro: options.distro, + }; +}; diff --git a/src/modules/appListSync.ts b/src/modules/appListSync.ts new file mode 100644 index 00000000..bd3ffee1 --- /dev/null +++ b/src/modules/appListSync.ts @@ -0,0 +1,76 @@ +import type { App, SyncedAppListItem } from "@/global/typedefinition"; +import { parsePackageArch } from "@/modules/appIdentity"; + +const hasUsablePackageIdentity = (app: App): boolean => { + return app.pkgname.trim().length > 0 && Boolean(app.origin); +}; + +export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => { + return apps + .filter( + (app) => + app.currentStatus === "installed" && + app.category !== "unknown" && + !app.isDependency && + hasUsablePackageIdentity(app), + ) + .map((app) => ({ + pkgname: app.pkgname, + origin: app.origin, + category: app.category, + version: app.version, + packageArch: app.arch || parsePackageArch(app.filename), + appName: app.name || app.pkgname, + iconUrl: app.icons || "", + })); +}; + +export const cloudItemKey = ( + item: Pick, +): string => `${item.origin}:${item.pkgname}`; + +export const cloudPackageKey = ( + item: Pick, +): string => item.pkgname; + +export const mergeInstalledApps = ( + currentApps: App[], + refreshedApps: App[], + refreshedOrigins: Array<"spark" | "apm">, +): App[] => { + const refreshedKeys = new Set( + refreshedApps.map((app) => `${app.origin}:${app.pkgname}`), + ); + + return [ + ...currentApps.filter( + (app) => + !refreshedOrigins.includes(app.origin) && + !refreshedKeys.has(`${app.origin}:${app.pkgname}`), + ), + ...refreshedApps, + ]; +}; + +export const resolveCloudInstallCandidate = ( + item: SyncedAppListItem, + apps: App[], +): App | null => { + const exactMatch = apps.find( + (app) => + app.pkgname === item.pkgname && + app.origin === item.origin && + app.category === item.category, + ); + + const sameOriginPackageMatch = apps.find( + (app) => app.pkgname === item.pkgname && app.origin === item.origin, + ); + + return ( + exactMatch ?? + sameOriginPackageMatch ?? + apps.find((app) => app.pkgname === item.pkgname) ?? + null + ); +}; diff --git a/src/modules/backendApi.ts b/src/modules/backendApi.ts new file mode 100644 index 00000000..1c59a5a8 --- /dev/null +++ b/src/modules/backendApi.ts @@ -0,0 +1,545 @@ +import axios, { type AxiosResponse } from "axios"; +import pino from "pino"; + +import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig"; +import type { + AppReview, + AppReviewReply, + AuthSession, + DownloadedAppList, + DownloadedAppRecord, + FavoriteFolder, + FavoriteItem, + RatingSummary, + ReviewTags, + SparkUser, + SyncedAppList, + SyncedAppListItem, +} from "@/global/typedefinition"; + +const backend = axios.create({ + baseURL: SPARK_BACKEND_BASE_URL, + timeout: 10000, +}); +const logger = pino({ name: "backendApi" }); + +type ApiRecord = Record; + +const normalizeBackendAuthError = (error: unknown): Error => { + if (!axios.isAxiosError(error)) { + return error instanceof Error ? error : new Error("登录失败,请稍后重试。"); + } + + logger.error( + { + code: error.code, + message: error.message, + status: error.response?.status, + }, + "Spark backend auth exchange failed", + ); + + if (!error.response) { + return new Error("无法连接星火账号服务,请确认后端服务已启动或稍后重试。"); + } + + const status = error.response.status; + if (status === 401) { + return new Error("论坛登录失败,请检查账号和密码。"); + } + if (status === 503) { + return new Error("星火账号服务暂时无法连接论坛,请稍后重试。"); + } + if (status === 500) { + return new Error("星火账号服务异常,请确认后端数据库迁移已执行后重试。"); + } + + return new Error(`星火账号服务返回异常 (${status}),请稍后重试。`); +}; + +const normalizeBackendMutationError = (error: unknown): Error => { + if (!axios.isAxiosError(error)) { + return error instanceof Error ? error : new Error("操作失败,请稍后重试。"); + } + + logger.error( + { + code: error.code, + message: error.message, + status: error.response?.status, + }, + "Spark backend mutation failed", + ); + + if (!error.response) { + return new Error("无法连接星火账号服务,请稍后重试。"); + } + + const status = error.response.status; + if (status === 401) { + return new Error("登录状态已失效,请重新登录星火账号。"); + } + if (status === 403) { + return new Error("请登录星火账号后重试。"); + } + if (status === 422) { + return new Error("提交内容格式不正确,请检查后重试。"); + } + if (status >= 500) { + return new Error("星火账号服务异常,请稍后重试。"); + } + + return new Error(`星火账号服务返回异常 (${status}),请稍后重试。`); +}; + +const asApiRecord = (value: unknown): ApiRecord => { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as ApiRecord; + } + return {}; +}; + +const asApiRecordArray = (value: unknown): ApiRecord[] => { + if (!Array.isArray(value)) return []; + return value.map(asApiRecord); +}; + +export const parseForumGroups = (raw: unknown): string[] => { + if (Array.isArray(raw)) { + return raw.filter((item): item is string => typeof item === "string"); + } + if (typeof raw !== "string" || raw.length === 0) return []; + + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === "string") + : []; + } catch { + return []; + } +}; + +const toUser = (raw: ApiRecord): SparkUser => ({ + id: Number(raw.id), + flarumUserId: String(raw.flarum_user_id || ""), + username: String(raw.username || ""), + displayName: String(raw.display_name || raw.username || ""), + avatarUrl: String(raw.avatar_url || ""), + coverUrl: String(raw.cover_url || raw.coverUrl || "") || undefined, + forumLevel: String(raw.forum_level || "论坛用户"), + forumGroups: parseForumGroups(raw.forum_groups), +}); + +const toReview = (raw: ApiRecord): AppReview => ({ + id: Number(raw.id), + userId: raw.user_id === undefined ? undefined : Number(raw.user_id), + rating: Number(raw.rating), + content: String(raw.content || ""), + version: String(raw.version || "unknown"), + packageArch: String(raw.package_arch || "unknown"), + clientArch: String(raw.client_arch || "unknown"), + distro: String(raw.distro || "unknown"), + origin: raw.origin === "spark" ? "spark" : "apm", + category: String(raw.category || ""), + createdAt: String(raw.created_at || ""), + updatedAt: String(raw.updated_at || ""), + userDisplayName: String(raw.user_display_name || ""), + userAvatarUrl: String(raw.user_avatar_url || ""), + likeCount: Number(raw.like_count || 0), + likedByCurrentUser: Boolean(raw.liked_by_current_user), + canDelete: raw.can_delete === undefined ? undefined : Boolean(raw.can_delete), + isAuthor: raw.is_author === undefined ? undefined : Boolean(raw.is_author), + isDeleted: Boolean(raw.is_deleted), + replies: asApiRecordArray(raw.replies).map(toReviewReply), +}); + +const toReviewReply = (raw: ApiRecord): AppReviewReply => ({ + id: Number(raw.id), + reviewId: Number(raw.review_id), + parentId: + raw.parent_id === null || raw.parent_id === undefined + ? null + : Number(raw.parent_id), + content: String(raw.content || ""), + createdAt: String(raw.created_at || ""), + updatedAt: String(raw.updated_at || ""), + userDisplayName: String(raw.user_display_name || ""), + userAvatarUrl: String(raw.user_avatar_url || ""), + likeCount: Number(raw.like_count || 0), + likedByCurrentUser: Boolean(raw.liked_by_current_user), + canDelete: Boolean(raw.can_delete), + isAuthor: Boolean(raw.is_author), + isDeleted: Boolean(raw.is_deleted), + replies: asApiRecordArray(raw.replies).map(toReviewReply), +}); + +export interface ReviewActionState { + likedByCurrentUser: boolean; + likeCount: number; +} + +export interface CreateReviewReplyPayload { + content: string; + parentId?: number; +} + +const toReviewActionState = (raw: ApiRecord): ReviewActionState => ({ + likedByCurrentUser: Boolean(raw.liked_by_current_user), + likeCount: Number(raw.like_count || 0), +}); + +const toFavoriteFolder = (raw: ApiRecord): FavoriteFolder => ({ + id: Number(raw.id), + name: String(raw.name || ""), + itemCount: Number(raw.item_count || 0), + createdAt: String(raw.created_at || ""), + updatedAt: String(raw.updated_at || ""), +}); + +const toFavoriteItem = (raw: ApiRecord): FavoriteItem => ({ + id: Number(raw.id), + appKey: String(raw.app_key || ""), + pkgname: String(raw.pkgname || ""), + name: String(raw.name || ""), + category: String(raw.category || ""), + iconUrl: String(raw.icon_url || ""), + createdAt: String(raw.created_at || ""), +}); + +const toDownloadedApp = (raw: ApiRecord): DownloadedAppRecord => ({ + id: Number(raw.id), + appKey: String(raw.app_key || ""), + pkgname: String(raw.pkgname || ""), + name: String(raw.name || ""), + category: String(raw.category || ""), + selectedOrigin: raw.selected_origin === "spark" ? "spark" : "apm", + version: String(raw.version || ""), + packageArch: String(raw.package_arch || "unknown"), + downloadedAt: String(raw.downloaded_at || ""), +}); + +const toSyncedAppListItem = (raw: ApiRecord): SyncedAppListItem => ({ + id: raw.id === undefined ? undefined : Number(raw.id), + pkgname: String(raw.pkgname || ""), + origin: raw.origin === "spark" ? "spark" : "apm", + category: String(raw.category || ""), + version: String(raw.version || ""), + packageArch: String(raw.package_arch || "unknown"), + appName: String(raw.app_name || ""), + iconUrl: String(raw.icon_url || ""), +}); + +const toSyncedAppList = ( + raw: ApiRecord, + fallback?: { clientArch: string; distro: string; items: SyncedAppListItem[] }, +): SyncedAppList => ({ + snapshotName: String(raw.snapshot_name || "默认列表"), + clientArch: String(raw.client_arch || fallback?.clientArch || "unknown"), + distro: String(raw.distro || fallback?.distro || "unknown"), + updatedAt: String(raw.updated_at || ""), + items: raw.items + ? asApiRecordArray(raw.items).map(toSyncedAppListItem) + : fallback?.items || [], +}); + +export const setBackendToken = (token: string | null): void => { + const backendWithOptionalDefaults = backend as typeof backend & { + defaults?: { headers?: { common?: Record } }; + }; + const commonHeaders = backendWithOptionalDefaults.defaults?.headers?.common; + if (!commonHeaders) return; + + if (token) commonHeaders.Authorization = `Bearer ${token}`; + else delete commonHeaders.Authorization; +}; + +export const exchangeFlarumToken = async (payload: { + flarumUserId: string; + flarumToken: string; +}): Promise => { + let response: AxiosResponse; + try { + response = await backend.post("/auth/flarum", { + flarum_user_id: payload.flarumUserId, + flarum_token: payload.flarumToken, + }); + } catch (error) { + throw normalizeBackendAuthError(error); + } + const data = asApiRecord(response.data); + + return { + accessToken: String(data.access_token || ""), + tokenType: "bearer", + user: toUser(asApiRecord(data.user)), + }; +}; + +export const fetchMe = async (): Promise => { + const response = await backend.get("/me"); + return toUser(asApiRecord(response.data)); +}; + +export const fetchRatingSummary = async ( + appKey: string, +): Promise => { + const response = await backend.get( + `/apps/${encodeURIComponent(appKey)}/rating-summary`, + ); + const data = asApiRecord(response.data); + + return { + averageRating: Number(data.average_rating || 0), + reviewCount: Number(data.review_count || 0), + starCounts: Object.fromEntries( + Object.entries(asApiRecord(data.star_counts)).map(([key, value]) => [ + Number(key), + Number(value), + ]), + ), + }; +}; + +export const fetchReviews = async (appKey: string): Promise => { + const response = await backend.get( + `/apps/${encodeURIComponent(appKey)}/reviews`, + ); + return asApiRecordArray(response.data).map(toReview); +}; + +export const submitReview = async ( + appKey: string, + payload: { rating: number; content: string; tags: ReviewTags }, +): Promise => { + let response: AxiosResponse; + try { + response = await backend.post( + `/apps/${encodeURIComponent(appKey)}/reviews`, + { + rating: payload.rating, + content: payload.content, + tags: { + origin: payload.tags.origin, + category: payload.tags.category, + pkgname: payload.tags.pkgname, + version: payload.tags.version, + package_arch: payload.tags.packageArch, + client_arch: payload.tags.clientArch, + distro: payload.tags.distro, + }, + }, + ); + } catch (error) { + throw normalizeBackendMutationError(error); + } + return toReview(asApiRecord(response.data)); +}; + +export const likeReview = async ( + appKey: string, + reviewId: number, +): Promise => { + let response: AxiosResponse; + try { + response = await backend.post( + `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/like`, + ); + } catch (error) { + throw normalizeBackendMutationError(error); + } + return toReviewActionState(asApiRecord(response.data)); +}; + +export const createReviewReply = async ( + appKey: string, + reviewId: number, + payload: CreateReviewReplyPayload, +): Promise => { + let response: AxiosResponse; + try { + response = await backend.post( + `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies`, + { + content: payload.content, + ...(payload.parentId === undefined + ? {} + : { parent_id: payload.parentId }), + }, + ); + } catch (error) { + throw normalizeBackendMutationError(error); + } + return toReviewReply(asApiRecord(response.data)); +}; + +export const deleteReview = async ( + appKey: string, + reviewId: number, +): Promise => { + try { + await backend.delete( + `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}`, + ); + } catch (error) { + throw normalizeBackendMutationError(error); + } +}; + +export const likeReviewReply = async ( + appKey: string, + reviewId: number, + replyId: number, +): Promise => { + let response: AxiosResponse; + try { + response = await backend.post( + `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies/${replyId}/like`, + ); + } catch (error) { + throw normalizeBackendMutationError(error); + } + return toReviewActionState(asApiRecord(response.data)); +}; + +export const deleteReviewReply = async ( + appKey: string, + reviewId: number, + replyId: number, +): Promise => { + try { + await backend.delete( + `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies/${replyId}`, + ); + } catch (error) { + throw normalizeBackendMutationError(error); + } +}; + +export const listFavoriteFolders = async (): Promise => { + const response = await backend.get("/me/favorite-folders"); + return asApiRecordArray(response.data).map(toFavoriteFolder); +}; + +export const createFavoriteFolder = async ( + name: string, +): Promise => { + const response = await backend.post("/me/favorite-folders", { name }); + return toFavoriteFolder(asApiRecord(response.data)); +}; + +export const renameFavoriteFolder = async ( + folderId: number, + name: string, +): Promise => { + const response = await backend.patch(`/me/favorite-folders/${folderId}`, { + name, + }); + return toFavoriteFolder(asApiRecord(response.data)); +}; + +export const deleteFavoriteFolder = async (folderId: number): Promise => { + await backend.delete(`/me/favorite-folders/${folderId}`); +}; + +export const listFavoriteItems = async ( + folderId: number, +): Promise => { + const response = await backend.get(`/me/favorite-folders/${folderId}/items`); + return asApiRecordArray(response.data).map(toFavoriteItem); +}; + +export const addFavoriteItem = async ( + folderId: number | "default", + item: Omit, +): Promise => { + const response = await backend.post( + `/me/favorite-folders/${folderId}/items`, + { + app_key: item.appKey, + pkgname: item.pkgname, + name: item.name, + category: item.category, + icon_url: item.iconUrl, + }, + ); + return toFavoriteItem(asApiRecord(response.data)); +}; + +export const deleteFavoriteItem = async ( + folderId: number, + itemId: number, +): Promise => { + await backend.delete(`/me/favorite-folders/${folderId}/items/${itemId}`); +}; + +export const bulkDeleteFavoriteItems = async ( + folderId: number, + itemIds: number[], +): Promise => { + const response = await backend.post( + `/me/favorite-folders/${folderId}/items/bulk-delete`, + { item_ids: itemIds }, + ); + return Number(asApiRecord(response.data).deleted_count || 0); +}; + +export const listDownloadedApps = async ( + page = 1, + pageSize = 20, +): Promise => { + const response = await backend.get("/me/downloaded-apps", { + params: { page, page_size: pageSize }, + }); + const data = asApiRecord(response.data); + + return { + items: asApiRecordArray(data.items).map(toDownloadedApp), + total: Number(data.total || 0), + page: Number(data.page || page), + pageSize: Number(data.page_size || pageSize), + }; +}; + +export const recordDownloadedApp = async ( + item: Omit, +): Promise => { + const response = await backend.post("/me/downloaded-apps", { + app_key: item.appKey, + pkgname: item.pkgname, + name: item.name, + category: item.category, + selected_origin: item.selectedOrigin, + version: item.version, + package_arch: item.packageArch, + }); + return toDownloadedApp(asApiRecord(response.data)); +}; + +export const fetchSyncedAppList = async (): Promise => { + const response = await backend.get("/me/app-list"); + if (!response.data) return null; + return toSyncedAppList(asApiRecord(response.data)); +}; + +export const uploadSyncedAppList = async (payload: { + clientArch: string; + distro: string; + items: SyncedAppListItem[]; +}): Promise => { + const response = await backend.put("/me/app-list", { + client_arch: payload.clientArch, + distro: payload.distro, + items: payload.items.map((item) => ({ + pkgname: item.pkgname, + origin: item.origin, + category: item.category, + version: item.version, + package_arch: item.packageArch, + app_name: item.appName, + icon_url: item.iconUrl, + })), + }); + + return toSyncedAppList(asApiRecord(response.data), payload); +}; diff --git a/src/modules/favoriteAvailability.ts b/src/modules/favoriteAvailability.ts new file mode 100644 index 00000000..0c0ba399 --- /dev/null +++ b/src/modules/favoriteAvailability.ts @@ -0,0 +1,114 @@ +import { getHybridDefaultOrigin } from "@/global/storeConfig"; +import type { + App, + FavoriteItem, + ResolvedFavoriteItem, + StoreFilter, +} from "@/global/typedefinition"; + +type SourceAvailability = { + spark: boolean; + apm: boolean; +}; + +const normalizeArch = (arch: string): string => + arch.replace(/-(store|apm)$/, ""); + +const appMatchesFavorite = (app: App, item: FavoriteItem): boolean => + app.pkgname === item.pkgname && app.category === item.category; + +const installedAppMatchesFavorite = (app: App, item: FavoriteItem): boolean => + app.pkgname === item.pkgname && + (app.category === item.category || + app.category === "all" || + app.category === "unknown"); + +const appMatchesClientArch = (app: App, clientArch: string): boolean => { + if (!app.arch) return true; + return normalizeArch(app.arch) === normalizeArch(clientArch); +}; + +const sourceAllowed = ( + origin: "spark" | "apm", + available: SourceAvailability, + storeFilter: StoreFilter, +): boolean => { + if (!available[origin]) return false; + if (storeFilter === "both") return true; + return storeFilter === origin; +}; + +const choosePreferredApp = (apps: App[]): App => { + if (apps.length === 1) return apps[0]; + + const referenceApp = apps.find((app) => app.origin === "spark") ?? apps[0]; + const preferredOrigin = getHybridDefaultOrigin(referenceApp); + return apps.find((app) => app.origin === preferredOrigin) ?? apps[0]; +}; + +export const resolveFavoriteItems = ( + items: FavoriteItem[], + catalogApps: App[], + installedApps: App[], + available: SourceAvailability, + storeFilter: StoreFilter, + clientArch = window.apm_store.arch || "amd64", +): ResolvedFavoriteItem[] => { + return items.map((item) => { + const catalogMatches = catalogApps.filter((app) => + appMatchesFavorite(app, item), + ); + + if (catalogMatches.length === 0) { + return { + item, + status: "downlisted", + reason: "已下架", + selectedApp: null, + }; + } + + const installedMatch = installedApps.find((app) => + installedAppMatchesFavorite(app, item), + ); + if (installedMatch) { + return { + item, + status: "installed", + reason: "已安装", + selectedApp: installedMatch, + }; + } + + const archMatches = catalogMatches.filter((app) => + appMatchesClientArch(app, clientArch), + ); + if (archMatches.length === 0) { + return { + item, + status: "arch-unavailable", + reason: "当前架构不可用", + selectedApp: null, + }; + } + + const sourceMatches = archMatches.filter((app) => + sourceAllowed(app.origin, available, storeFilter), + ); + if (sourceMatches.length === 0) { + return { + item, + status: "platform-unavailable", + reason: "当前来源不可用", + selectedApp: null, + }; + } + + return { + item, + status: "installable", + reason: "可安装", + selectedApp: choosePreferredApp(sourceMatches), + }; + }); +}; diff --git a/src/modules/flarumAuth.ts b/src/modules/flarumAuth.ts new file mode 100644 index 00000000..b136c24b --- /dev/null +++ b/src/modules/flarumAuth.ts @@ -0,0 +1,60 @@ +import type { FlarumLoginPayload } from "@/global/typedefinition"; + +type FlarumTokenResponse = { + token: string; + userId: string; +}; + +const asRecord = (value: unknown): Record => { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return {}; +}; + +const knownLoginErrorMessages = [ + "无法连接星火论坛,请检查网络后重试。", + "论坛登录失败,请检查账号和密码。", + "论坛登录响应异常,请稍后重试。", + "登录信息格式不正确,请重新输入。", +]; + +const normalizeIpcError = (error: unknown): Error => { + if (!(error instanceof Error)) { + return new Error("登录失败,请稍后重试"); + } + + const knownMessage = knownLoginErrorMessages.find((message) => + error.message.includes(message), + ); + return knownMessage ? new Error(knownMessage) : error; +}; + +export const requestFlarumToken = async ( + payload: FlarumLoginPayload, +): Promise => { + let data: Record; + try { + data = asRecord( + await window.ipcRenderer.invoke("request-flarum-token", payload), + ); + } catch (error) { + throw normalizeIpcError(error); + } + + const token = data.token; + const userId = data.userId ?? data.user_id; + if ( + typeof token !== "string" || + !token || + userId === undefined || + userId === null + ) { + throw new Error("论坛登录响应异常,请稍后重试。"); + } + + return { + token, + userId: String(userId), + }; +}; diff --git a/src/modules/processInstall.ts b/src/modules/processInstall.ts index 67c77c61..397003a0 100644 --- a/src/modules/processInstall.ts +++ b/src/modules/processInstall.ts @@ -21,16 +21,18 @@ import axios from "axios"; const logger = pino({ name: "processInstall.ts" }); -export const handleInstall = async (appObj?: App) => { +export const handleInstall = async ( + appObj?: App, +): Promise => { const targetApp = appObj || currentApp.value; - if (!targetApp?.pkgname) return; + if (!targetApp?.pkgname) return null; // APM 应用:在创建下载任务前检查 APM 是否可用 if (targetApp.origin === "apm") { const hasApm = await window.ipcRenderer.invoke("check-apm-available"); if (!hasApm) { showApmInstallDialog.value = true; - return; + return null; } } @@ -42,7 +44,7 @@ export const handleInstall = async (appObj?: App) => { logger.info( `任务已存在,忽略重复添加: ${targetApp.pkgname} (${targetApp.origin})`, ); - return; + return null; } // 创建下载任务 @@ -98,6 +100,7 @@ export const handleInstall = async (appObj?: App) => { .then((response) => { logger.info("下载次数统计已发送,状态:", response.data); }); + return download; }; export const handleRetry = (download_: DownloadItem) => { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 3de6f018..d33ad11e 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,7 +1,7 @@ /* eslint-disable */ /// -import type { UpdateCenterBridge } from "@/global/typedefinition"; +import type { SystemInfo, UpdateCenterBridge } from "@/global/typedefinition"; declare module "*.vue" { import type { DefineComponent } from "vue"; @@ -10,12 +10,17 @@ declare module "*.vue" { } declare global { + interface ImportMetaEnv { + readonly VITE_SPARK_BACKEND_BASE_URL?: string; + } + interface Window { // expose in the `electron/preload/index.ts` ipcRenderer: IpcRendererFacade; apm_store: { arch: string; }; + windowControls: WindowControlBridge; updateCenter: UpdateCenterBridge; } } @@ -27,9 +32,20 @@ interface IpcRendererFacade { invoke: import("electron").IpcRenderer["invoke"]; } +interface WindowControlBridge { + minimize: () => void; + toggleMaximize: () => void; + close: () => void; +} + // IPC channel type definitions declare interface IpcChannels { "get-app-version": () => string; + "get-system-info": () => Promise; + "request-flarum-token": (payload: { + identification: string; + password: string; + }) => Promise<{ token: string; userId: string }>; } declare const __APP_VERSION__: string;