.codex/agents/check.toml
New file @@ -0,0 +1,23 @@ name = "check" description = "Read-only Trellis reviewer focused on correctness, missing tests, and spec drift." sandbox_mode = "read-only" developer_instructions = """ You are the Trellis reviewer agent. Review checklist: - Verify behavior against the actual code paths, not assumptions. - Look for missing template/update/detection touch points when platform config changes. - Check whether tests should be added or updated. - Check whether `.trellis/spec/` docs need sync after implementation. - Prefer concrete findings over speculative warnings. Output format: ## Findings - Severity: <high|medium|low> - File: <path> - Issue: <what is wrong> - Recommendation: <specific fix> If no issues are found, say so explicitly. """ .codex/agents/implement.toml
New file @@ -0,0 +1,19 @@ name = "implement" description = "Workspace-write Trellis implementer that follows specs and keeps generated templates in sync." sandbox_mode = "workspace-write" developer_instructions = """ You are the Trellis implementer agent. Rules: - Read before write. Follow `.trellis/spec/` guidance relevant to the task. - Keep changes focused on the requested scope. - When touching platform registries or template lists, search first so you do not miss mirrored update paths. - If you modify `.trellis/scripts/`, keep `packages/cli/src/templates/trellis/scripts/` in sync. - Do not make destructive git changes unless explicitly asked. Before finishing, summarize: - Files changed - Tests/checks run - Remaining risks or follow-ups """ .codex/agents/research.toml
New file @@ -0,0 +1,26 @@ name = "research" description = "Read-only Trellis researcher for specs, code patterns, and affected files." sandbox_mode = "read-only" developer_instructions = """ You are the Trellis researcher agent. Responsibilities: - Read `.trellis/workflow.md`, relevant `.trellis/spec/` files, and target code before proposing changes. - Identify the smallest set of relevant specs, code patterns, and files to modify. - Call out cross-layer or cross-platform risks when they are real. - Do not edit files. Output format: ## Relevant Specs - <path>: <why> ## Code Patterns Found - <pattern>: <file> ## Files to Modify - <path>: <change> ## Risks / Follow-ups - <none or concrete note> """ .codex/config.toml
New file @@ -0,0 +1,5 @@ # Project-scoped Codex defaults for Trellis workflows. # Codex loads this after ~/.codex/config.toml when you work in this project. # Keep AGENTS.md as the primary project instruction file. project_doc_fallback_filenames = ["AGENTS.md"] .codex/hooks.json
New file @@ -0,0 +1,16 @@ { "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "python3 .codex/hooks/session-start.py", "timeout": 15, "statusMessage": "Loading Trellis context..." } ] } ] } } .codex/hooks/session-start.py
New file @@ -0,0 +1,243 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Codex Session Start Hook - Inject Trellis context into Codex sessions. Output format follows Codex hook protocol: stdout JSON → { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: "..." } } """ from __future__ import annotations import json import os import subprocess import sys import warnings from io import StringIO from pathlib import Path warnings.filterwarnings("ignore") def should_skip_injection() -> bool: return os.environ.get("CODEX_NON_INTERACTIVE") == "1" def read_file(path: Path, fallback: str = "") -> str: try: return path.read_text(encoding="utf-8") except (FileNotFoundError, PermissionError): return fallback def run_script(script_path: Path) -> str: try: env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" cmd = [sys.executable, "-W", "ignore", str(script_path)] result = subprocess.run( cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, cwd=str(script_path.parent.parent.parent), env=env, ) return result.stdout if result.returncode == 0 else "No context available" except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): return "No context available" def _normalize_task_ref(task_ref: str) -> str: normalized = task_ref.strip() if not normalized: return "" path_obj = Path(normalized) if path_obj.is_absolute(): return str(path_obj) normalized = normalized.replace("\\", "/") while normalized.startswith("./"): normalized = normalized[2:] if normalized.startswith("tasks/"): return f".trellis/{normalized}" return normalized def _resolve_task_dir(trellis_dir: Path, task_ref: str) -> Path: normalized = _normalize_task_ref(task_ref) path_obj = Path(normalized) if path_obj.is_absolute(): return path_obj if normalized.startswith(".trellis/"): return trellis_dir.parent / path_obj return trellis_dir / "tasks" / path_obj def _get_task_status(trellis_dir: Path) -> str: current_task_file = trellis_dir / ".current-task" if not current_task_file.is_file(): return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" task_ref = _normalize_task_ref(current_task_file.read_text(encoding="utf-8").strip()) if not task_ref: return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" task_dir = _resolve_task_dir(trellis_dir, task_ref) if not task_dir.is_dir(): return f"Status: STALE POINTER\nTask: {task_ref}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish" task_json_path = task_dir / "task.json" task_data: dict = {} if task_json_path.is_file(): try: task_data = json.loads(task_json_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, PermissionError): pass task_title = task_data.get("title", task_ref) task_status = task_data.get("status", "unknown") if task_status == "completed": return f"Status: COMPLETED\nTask: {task_title}\nNext: Archive with `python3 ./.trellis/scripts/task.py archive {task_dir.name}` or start a new task" has_context = False for jsonl_name in ("implement.jsonl", "check.jsonl", "spec.jsonl"): jsonl_path = task_dir / jsonl_name if jsonl_path.is_file() and jsonl_path.stat().st_size > 0: has_context = True break has_prd = (task_dir / "prd.md").is_file() if not has_prd: return f"Status: NOT READY\nTask: {task_title}\nMissing: prd.md not created\nNext: Write PRD, then research → init-context → start" if not has_context: return f"Status: NOT READY\nTask: {task_title}\nMissing: Context not configured (no jsonl files)\nNext: Complete Phase 2 (research → init-context → start) before implementing" return f"Status: READY\nTask: {task_title}\nNext: Continue with implement or check" def _build_workflow_toc(workflow_path: Path) -> str: """Build a compact section index for workflow.md (lazy-load the full file on demand). Replaces full-file injection to keep additionalContext payload small. The full file is accessible via: Read tool on .trellis/workflow.md """ content = read_file(workflow_path) if not content: return "No workflow.md found" toc_lines = [ "# Development Workflow — Section Index", "Full guide: .trellis/workflow.md (read on demand)", "", ] for line in content.splitlines(): if line.startswith("## "): toc_lines.append(line) toc_lines += [ "", "To read a section: use the Read tool on .trellis/workflow.md", ] return "\n".join(toc_lines) def main() -> None: if should_skip_injection(): sys.exit(0) # Read hook input from stdin try: hook_input = json.loads(sys.stdin.read()) project_dir = Path(hook_input.get("cwd", ".")).resolve() except (json.JSONDecodeError, KeyError): project_dir = Path(".").resolve() trellis_dir = project_dir / ".trellis" output = StringIO() output.write("""<session-context> You are starting a new session in a Trellis-managed project. Read and follow all instructions below carefully. </session-context> """) output.write("<current-state>\n") context_script = trellis_dir / "scripts" / "get_context.py" output.write(run_script(context_script)) output.write("\n</current-state>\n\n") output.write("<workflow>\n") output.write(_build_workflow_toc(trellis_dir / "workflow.md")) output.write("\n</workflow>\n\n") output.write("<guidelines>\n") output.write("**Note**: The guidelines below are index files — they list available guideline documents and their locations.\n") output.write("During actual development, you MUST read the specific guideline files listed in each index's Pre-Development Checklist.\n\n") spec_dir = trellis_dir / "spec" if spec_dir.is_dir(): for sub in sorted(spec_dir.iterdir()): if not sub.is_dir() or sub.name.startswith("."): continue if sub.name == "guides": index_file = sub / "index.md" if index_file.is_file(): output.write(f"## {sub.name}\n") output.write(read_file(index_file)) output.write("\n\n") continue index_file = sub / "index.md" if index_file.is_file(): output.write(f"## {sub.name}\n") output.write(read_file(index_file)) output.write("\n\n") else: for nested in sorted(sub.iterdir()): if not nested.is_dir(): continue nested_index = nested / "index.md" if nested_index.is_file(): output.write(f"## {sub.name}/{nested.name}\n") output.write(read_file(nested_index)) output.write("\n\n") output.write("</guidelines>\n\n") task_status = _get_task_status(trellis_dir) output.write(f"<task-status>\n{task_status}\n</task-status>\n\n") output.write("""<ready> Context loaded. Workflow index, project state, and guidelines are already injected above — do NOT re-read them. Wait for the user's first message, then handle it following the workflow guide. If there is an active task, ask whether to continue it. </ready>""") context = output.getvalue() result = { "suppressOutput": True, "systemMessage": f"Trellis context injected ({len(context)} chars)", "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": context, }, } print(json.dumps(result, ensure_ascii=False), flush=True) if __name__ == "__main__": main() .codex/skills/parallel/SKILL.md
New file @@ -0,0 +1,194 @@ --- name: parallel description: "Multi-agent pipeline orchestrator that plans and dispatches parallel development tasks to worktree agents. Reads project context, configures task directories with PRDs and jsonl context files, and launches isolated coding agents. Use when multiple independent features need parallel development, orchestrating worktree agents, or managing multi-agent coding pipelines." --- # Multi-Agent Pipeline Orchestrator You are the Multi-Agent Pipeline Orchestrator Agent, running in the main repository, responsible for collaborating with users to manage parallel development tasks. ## Role Definition - **You are in the main repository**, not in a worktree - **You don't write code directly** - code work is done by agents in worktrees - **You are responsible for planning and dispatching**: discuss requirements, create plans, configure context, start worktree agents - **Delegate complex analysis to research**: find specs, inspect code structure, and reduce ambiguity before dispatch --- ## Operation Types Operations in this document are categorized as: | Marker | Meaning | Executor | |--------|---------|----------| | `[AI]` | Bash scripts or tool calls executed by AI | You (AI) | | `[USER]` | Skills executed by user | User | --- ## Startup Flow ### Step 1: Understand Trellis Workflow `[AI]` First, read the workflow guide to understand the development process: ```bash cat .trellis/workflow.md # Development process, conventions, and quick start guide ``` ### Step 2: Get Current Status `[AI]` ```bash python3 ./.trellis/scripts/get_context.py ``` ### Step 3: Read Project Guidelines `[AI]` ```bash python3 ./.trellis/scripts/get_context.py --mode packages # Discover available spec layers cat .trellis/spec/guides/index.md # Thinking guides ``` ### Step 4: Ask User for Requirements Ask the user: 1. What feature to develop? 2. Which modules are involved? 3. Development type? (backend / frontend / fullstack) --- ## Planning: Choose Your Approach Based on requirement complexity, choose one of these approaches: ### Option A: Plan Agent (Recommended for complex features) `[AI]` Use when: - Requirements need analysis and validation - Multiple modules or cross-layer changes - Unclear scope that needs research ```bash python3 ./.trellis/scripts/multi_agent/plan.py \ --name "<feature-name>" \ --type "<backend|frontend|fullstack>" \ --requirement "<user requirement description>" \ --platform codex ``` Plan Agent will: 1. Evaluate requirement validity (may reject if unclear/too large) 2. Analyze the codebase and specs 3. Create and configure task directory 4. Write `prd.md` with acceptance criteria 5. Output a ready-to-use task directory After `plan.py` completes, start the worktree agent: ```bash python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" --platform codex ``` ### Option B: Manual Configuration (For simple or already-clear features) `[AI]` Use when: - Requirements are already clear and specific - You know exactly which files are involved - Simple, well-scoped changes #### Step 1: Create Task Directory ```bash TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<title>" --slug <task-name>) ``` #### Step 2: Configure Task ```bash python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <dev_type> python3 ./.trellis/scripts/task.py set-branch "$TASK_DIR" feature/<name> python3 ./.trellis/scripts/task.py set-scope "$TASK_DIR" <scope> ``` #### Step 3: Add Context ```bash python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" ``` #### Step 4: Create `prd.md` ```bash cat > "$TASK_DIR/prd.md" << 'END_PRD' # Feature: <name> ## Requirements - ... ## Acceptance Criteria - ... END_PRD ``` #### Step 5: Validate and Start ```bash python3 ./.trellis/scripts/task.py validate "$TASK_DIR" python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" --platform codex ``` --- ## After Starting: Report Status Tell the user the agent has started and provide monitoring commands. --- ## User Available Skills `[USER]` The following skills are for users (not AI): | Skill | Description | |-------|-------------| | `$parallel` | Start Multi-Agent Pipeline (this skill) | | `$start` | Start normal development mode (single process) | | `$record-session` | Record session progress | | `$finish-work` | Pre-completion checklist | --- ## Monitoring Commands (for user reference) Tell the user they can use these commands to monitor: ```bash python3 ./.trellis/scripts/multi_agent/status.py # Overview python3 ./.trellis/scripts/multi_agent/status.py --log <name> # View log python3 ./.trellis/scripts/multi_agent/status.py --watch <name> # Real-time monitoring python3 ./.trellis/scripts/multi_agent/cleanup.py <branch> # Cleanup worktree ``` --- ## Pipeline Phases The dispatch agent in the worktree will automatically execute: 1. implement → Implement feature 2. check → Check code quality 3. finish → Final verification 4. create-pr → Create PR --- ## Core Rules - **Don't write code directly** - delegate to agents in worktrees - **Don't execute git commit** - the flow handles it in the worktree pipeline - **Delegate complex analysis before dispatch** - find specs, inspect code structure, and reduce ambiguity - **Prefer focused tasks** - parallelism works best when each worktree has a narrow scope doc/nginx.conf
@@ -99,6 +99,16 @@ index index.html index.htm; } location = /home { return 301 /home/; } location /home/ { alias /usr/local/project/tcm-training/home/; index index.html index.htm; try_files $uri $uri/ /home/index.html; } location /MP_verify_7FfidZ6E3EWJhVDd.txt { alias /usr/local/project/MP_verify_7FfidZ6E3EWJhVDd.txt; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/config/SaTokenWebConfig.java
@@ -1,11 +1,10 @@ package com.tcm.training.admin.config; import com.tcm.training.admin.app.auth.support.StpAppUserUtil; import cn.dev33.satoken.interceptor.SaInterceptor; import cn.dev33.satoken.context.SaHolder; import cn.dev33.satoken.stp.StpUtil; import com.tcm.training.admin.common.xss.XssParamInterceptor; import org.springframework.beans.factory.annotation.Value; import com.tcm.training.admin.upload.LocalUploadProperties; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; @@ -17,15 +16,12 @@ public class SaTokenWebConfig implements WebMvcConfigurer { private final XssParamInterceptor xssParamInterceptor; private final LocalUploadProperties localUploadProperties; @Value("${app.upload.dir:uploads}") private String uploadDir; @Value("${app.upload.path-prefix:/uploads}") private String pathPrefix; public SaTokenWebConfig(XssParamInterceptor xssParamInterceptor) { public SaTokenWebConfig(XssParamInterceptor xssParamInterceptor, LocalUploadProperties localUploadProperties) { this.xssParamInterceptor = xssParamInterceptor; this.localUploadProperties = localUploadProperties; } /** @@ -36,7 +32,7 @@ registry.addInterceptor(new SaInterceptor(handle -> handleAuth())) .addPathPatterns("/api/**", "/api/v1/**") .excludePathPatterns( pathPrefix + "/**" localUploadProperties.getPathPrefix() + "/**" ); registry.addInterceptor(xssParamInterceptor) @@ -84,8 +80,8 @@ @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 转为绝对路径,确保 file: 协议正确。 String absPath = new File(uploadDir).getAbsolutePath(); registry.addResourceHandler(pathPrefix + "/**") String absPath = new File(localUploadProperties.getDir()).getAbsolutePath(); registry.addResourceHandler(localUploadProperties.getPathPrefix() + "/**") .addResourceLocations("file:" + absPath + "/"); } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/upload/LocalAppPackageStorageService.java
New file @@ -0,0 +1,135 @@ package com.tcm.training.admin.upload; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.UUID; /** * 安装包本地存储服务。 */ @Service public class LocalAppPackageStorageService { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM/dd"); private final LocalUploadProperties localUploadProperties; public LocalAppPackageStorageService(LocalUploadProperties localUploadProperties) { this.localUploadProperties = localUploadProperties; } /** * 将安装包写入服务器本地目录,并返回可下载地址。 */ public UploadFileResult store(MultipartFile file, String platform) throws IOException { validateConfig(); String originalName = file.getOriginalFilename(); String extension = resolveExtension(originalName); String relativePath = buildRelativePath(platform, extension); Path targetPath = resolveTargetPath(relativePath); Files.createDirectories(targetPath.getParent()); file.transferTo(targetPath.toFile()); return new UploadFileResult(buildPublicUrl(relativePath), originalName, relativePath); } /** * 解析安装包最终写入路径。 */ Path resolveTargetPath(String relativePath) { Path uploadRoot = resolveUploadRoot(); String normalizedRelativePath = relativePath.replace("/", java.io.File.separator); return uploadRoot.resolve(normalizedRelativePath).normalize(); } /** * 生成对外下载地址。 */ String buildPublicUrl(String relativePath) { String baseUrl = trimTrailingSlash(localUploadProperties.getPublicBaseUrl()); String pathPrefix = ensureLeadingSlash(trimTrailingSlash(localUploadProperties.getPathPrefix())); return baseUrl + pathPrefix + "/" + relativePath; } /** * 组装安装包相对路径。 */ String buildRelativePath(String platform, String extension) { String datePath = LocalDate.now().format(DATE_FORMATTER); String fileName = UUID.randomUUID().toString().replace("-", "") + extension; return normalizePackageDir() + "/" + platform.toLowerCase() + "/" + datePath + "/" + fileName; } private Path resolveUploadRoot() { String uploadDir = localUploadProperties.getDir(); if (!StringUtils.hasText(uploadDir)) { throw new IllegalArgumentException("本地上传根目录未配置"); } Path uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize(); Path storageRoot = uploadRoot.resolve(normalizePackageDir()).normalize(); if (!storageRoot.startsWith(uploadRoot)) { throw new IllegalArgumentException("安装包存储目录必须位于上传根目录下"); } return uploadRoot; } private void validateConfig() { if (!StringUtils.hasText(localUploadProperties.getPublicBaseUrl())) { throw new IllegalArgumentException("安装包公网下载地址未配置"); } normalizePackageDir(); resolveUploadRoot(); } private String normalizePackageDir() { String appPackageDir = localUploadProperties.getAppPackageDir(); if (!StringUtils.hasText(appPackageDir)) { throw new IllegalArgumentException("安装包存储目录未配置"); } String normalized = trimSlash(appPackageDir.replace("\\", "/")); if (!StringUtils.hasText(normalized)) { throw new IllegalArgumentException("安装包存储目录未配置"); } if (normalized.startsWith("..") || normalized.contains("/../")) { throw new IllegalArgumentException("安装包存储目录不合法"); } return normalized; } private String resolveExtension(String originalName) { if (!StringUtils.hasText(originalName) || !originalName.contains(".")) { return ""; } return originalName.substring(originalName.lastIndexOf(".")).toLowerCase(); } private String trimTrailingSlash(String value) { String normalized = value == null ? "" : value.trim(); while (normalized.endsWith("/")) { normalized = normalized.substring(0, normalized.length() - 1); } return normalized; } private String ensureLeadingSlash(String value) { return value.startsWith("/") ? value : "/" + value; } private String trimSlash(String value) { String normalized = value == null ? "" : value.trim(); while (normalized.startsWith("/")) { normalized = normalized.substring(1); } while (normalized.endsWith("/")) { normalized = normalized.substring(0, normalized.length() - 1); } return normalized; } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/upload/LocalUploadProperties.java
New file @@ -0,0 +1,49 @@ package com.tcm.training.admin.upload; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; /** * 本地上传相关配置。 */ @Component @ConfigurationProperties(prefix = "app.upload") public class LocalUploadProperties { private String dir = "uploads"; private String pathPrefix = "/uploads"; private String publicBaseUrl; private String appPackageDir = "app-package"; public String getDir() { return dir; } public void setDir(String dir) { this.dir = dir; } public String getPathPrefix() { return pathPrefix; } public void setPathPrefix(String pathPrefix) { this.pathPrefix = pathPrefix; } public String getPublicBaseUrl() { return publicBaseUrl; } public void setPublicBaseUrl(String publicBaseUrl) { this.publicBaseUrl = publicBaseUrl; } public String getAppPackageDir() { return appPackageDir; } public void setAppPackageDir(String appPackageDir) { this.appPackageDir = appPackageDir; } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/upload/UploadController.java
@@ -45,13 +45,16 @@ private static final String PLATFORM_IOS = "IOS"; private final ObsUploadService obsUploadService; private final LocalAppPackageStorageService localAppPackageStorageService; private final UploadProgressService uploadProgressService; private final RemoteVideoDurationService remoteVideoDurationService; public UploadController(ObsUploadService obsUploadService, LocalAppPackageStorageService localAppPackageStorageService, UploadProgressService uploadProgressService, RemoteVideoDurationService remoteVideoDurationService) { this.obsUploadService = obsUploadService; this.localAppPackageStorageService = localAppPackageStorageService; this.uploadProgressService = uploadProgressService; this.remoteVideoDurationService = remoteVideoDurationService; } @@ -89,7 +92,7 @@ @RequestParam("platform") String platform) throws IOException { String normalizedPlatform = normalizePlatform(platform); validateAppPackage(file, normalizedPlatform); UploadFileResult uploadResult = obsUploadService.upload(file, "app-package/" + normalizedPlatform.toLowerCase(), null); UploadFileResult uploadResult = localAppPackageStorageService.store(file, normalizedPlatform); return ApiResponse.ok(new UploadResult(uploadResult.getUrl(), uploadResult.getFileName())); } tcm-training-admin-backend/src/main/resources/application-local.example.yml
@@ -25,6 +25,14 @@ cors: allowed-origin-patterns: "*" upload: # 上传根目录,部署时建议改为持久化绝对路径,例如 /data/tcm-training-admin/uploads dir: uploads # 静态访问前缀,需要与网关/Nginx 暴露路径保持一致 path-prefix: /uploads # 安装包目录固定写在上传根目录下 app-package-dir: app-package # 版本管理安装包对外下载域名基址,例如 https://api.example.com public-base-url: http://localhost:48080 obs: access-key-id: your-access-key-id secret-access-key: your-secret-access-key tcm-training-admin-backend/src/main/resources/application-prod.yml
@@ -31,6 +31,11 @@ # #连接池最大阻塞等待时间(使用负值表示没有限制) max-wait: -1ms app: upload: dir: /data/tcm-training-admin/uploads path-prefix: /uploads app-package-dir: app-package public-base-url: https://haizhentong.com auth: sms: aliyun: tcm-training-admin-backend/src/main/resources/application.yml
@@ -8,8 +8,8 @@ active: local servlet: multipart: max-file-size: 200MB max-request-size: 220MB max-file-size: 500MB max-request-size: 520MB datasource: dynamic: primary: master @@ -57,6 +57,10 @@ app: upload: dir: uploads path-prefix: /uploads app-package-dir: app-package public-base-url: http://192.168.110.188:48080:${server.port} obs: access-key-id: HPUAYLGWHP56VZRHQXGC secret-access-key: Cn3dL2iEf1h5d8ZLYUEz9ggy8BhwZMgA3SZhg3Zf tcm-training-admin-backend/src/main/resources/static/app-download/index.html
@@ -131,9 +131,98 @@ color: #c63520; min-height: 20px; } .wechat-guide { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 999; display: none; background: rgba(47, 29, 18, 0.72); padding: 20px 16px 28px; overflow-y: auto; } .wechat-guide.is-visible { display: block; } .wechat-guide__panel { width: 100%; max-width: 520px; margin: 12px auto 0; background: linear-gradient(180deg, #fffaf4 0%, #fff3e6 100%); border-radius: 24px; padding: 22px 18px 18px; box-shadow: 0 18px 48px rgba(0, 0, 0, 0.22); position: relative; } .wechat-guide__badge { display: inline-flex; align-items: center; border-radius: 999px; background: #ffe8cf; color: #b24a11; font-size: 12px; font-weight: 700; padding: 6px 10px; } .wechat-guide__title { margin: 14px 0 10px; font-size: 24px; line-height: 1.35; font-weight: 700; color: #2f1d12; } .wechat-guide__desc { margin: 0; color: #6f5b4d; font-size: 14px; line-height: 1.8; } .wechat-guide__steps { margin: 18px 0 0; padding: 16px 16px 16px 38px; border-radius: 18px; background: rgba(255, 255, 255, 0.72); color: #5b4638; font-size: 14px; line-height: 1.9; } .wechat-guide__hint { margin-top: 14px; color: #9b5f33; font-size: 13px; line-height: 1.7; } .wechat-guide__close { margin-top: 18px; width: 100%; border: none; border-radius: 14px; background: #c63520; color: #fff; font-size: 15px; font-weight: 600; padding: 14px 16px; cursor: pointer; } </style> </head> <body> <div class="wechat-guide" id="wechatGuide"> <div class="wechat-guide__panel"> <div class="wechat-guide__badge">微信内暂不支持直接下载</div> <h2 class="wechat-guide__title">请使用系统浏览器打开后再下载 APP</h2> <p class="wechat-guide__desc">微信内置浏览器会拦截安装包下载。请先通过右上角菜单切换到系统浏览器,再继续安装。</p> <ol class="wechat-guide__steps"> <li>点击右上角“...”。</li> <li>选择“在浏览器打开”。</li> <li>进入浏览器后,再点击下载按钮。</li> </ol> <div class="wechat-guide__hint">邀请码会保留在当前页面,切换到浏览器后仍可复制并用于注册绑定诊所。</div> <button class="wechat-guide__close" id="wechatGuideClose" type="button">我知道了</button> </div> </div> <div class="page"> <div class="card"> <div class="badge">中医培训 APP 下载</div> @@ -173,13 +262,17 @@ const state = { inviteCode: '', androidDownloadUrl: '', iosDownloadUrl: '' iosDownloadUrl: '', wechatBrowser: false } /** * 页面初始化。 */ async function bootstrap() { bindActions() detectWechatEnvironment() bindWechatEnvironmentListeners() const inviteCode = normalizeInviteCode(readQueryParam('inviteCode')) state.inviteCode = inviteCode renderInviteCode(inviteCode) @@ -187,7 +280,6 @@ if (inviteCode) { copyInviteCode(inviteCode, false) } bindActions() } /** @@ -257,12 +349,20 @@ } copyInviteCode(state.inviteCode, true) }) document.getElementById('wechatGuideClose').addEventListener('click', function () { hideWechatGuide() }) } /** * 处理下载动作,并在跳转前再次复制邀请码。 */ function handleDownload(url, platform) { if (shouldBlockDirectDownload()) { showWechatGuide() showStatus('请点击右上角,选择在浏览器中打开后再下载') return } const normalizedUrl = normalizeOptionalText(url) if (!normalizedUrl) { showStatus(platform === 'ANDROID' ? 'Android 安装包暂未配置' : 'iOS 安装包暂未配置') @@ -290,6 +390,74 @@ } /** * 根据当前环境刷新微信内提示。 */ function detectWechatEnvironment() { const matched = isWechatBrowser() state.wechatBrowser = matched if (matched) { showWechatGuide() showStatus('检测到微信内打开,请点击右上角,选择在浏览器中打开后再下载') } } /** * 绑定微信桥注入后的补充检测。 */ function bindWechatEnvironmentListeners() { document.addEventListener('WeixinJSBridgeReady', function () { detectWechatEnvironment() }, false) window.addEventListener('load', function () { window.setTimeout(function () { detectWechatEnvironment() }, 300) }) } /** * 判断当前是否为微信内置浏览器。 */ function isWechatBrowser() { if (state.wechatBrowser) { return true } const userAgent = String(window.navigator.userAgent || '').toLowerCase() if (userAgent.indexOf('micromessenger') >= 0 || userAgent.indexOf('wechat') >= 0) { return true } if (window.__wxjs_environment === 'miniprogram') { return true } if (typeof window.WeixinJSBridge !== 'undefined') { return true } return false } /** * 判断当前下载动作是否需要被微信环境拦截。 */ function shouldBlockDirectDownload() { state.wechatBrowser = isWechatBrowser() return state.wechatBrowser } /** * 展示微信内打开浏览器指引。 */ function showWechatGuide() { document.getElementById('wechatGuide').classList.add('is-visible') } /** * 隐藏微信内打开浏览器指引。 */ function hideWechatGuide() { document.getElementById('wechatGuide').classList.remove('is-visible') } /** * 复制邀请码到系统剪贴板。 */ function copyInviteCode(inviteCode, showSuccessStatus) { tcm-training-admin-backend/src/test/java/com/tcm/training/admin/live/service/LiveSessionAppServiceTest.java
@@ -65,6 +65,8 @@ @Captor private ArgumentCaptor<LiveSessionQuestion> questionCaptor; @Mock private LiveRemindDispatchService liveRemindDispatchService; /** * 每个用例前重建被测服务。 @@ -75,7 +77,8 @@ liveCourseMapper, liveSessionMapper, liveSessionQuestionMapper, liveReserveMapper liveReserveMapper, liveRemindDispatchService ); } tcm-training-admin-backend/src/test/java/com/tcm/training/admin/upload/LocalAppPackageStorageServiceTest.java
New file @@ -0,0 +1,94 @@ package com.tcm.training.admin.upload; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.springframework.mock.web.MockMultipartFile; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class LocalAppPackageStorageServiceTest { @TempDir Path tempDir; @Test void storeShouldWritePackageAndReturnPublicUrl() throws IOException { LocalUploadProperties properties = buildProperties(); LocalAppPackageStorageService service = new LocalAppPackageStorageService(properties); MockMultipartFile file = new MockMultipartFile( "file", "demo.apk", "application/vnd.android.package-archive", "apk-content".getBytes() ); UploadFileResult result = service.store(file, "ANDROID"); assertEquals("demo.apk", result.getFileName()); assertTrue(result.getUrl().startsWith("https://api.example.com/uploads/app-package/android/")); Path storedFile = tempDir.resolve(result.getObjectKey()); assertTrue(Files.exists(storedFile)); assertEquals("apk-content", new String(Files.readAllBytes(storedFile))); } @Test void storeShouldRejectMissingPublicBaseUrl() { LocalUploadProperties properties = buildProperties(); properties.setPublicBaseUrl(" "); LocalAppPackageStorageService service = new LocalAppPackageStorageService(properties); MockMultipartFile file = new MockMultipartFile( "file", "demo.apk", "application/vnd.android.package-archive", "apk-content".getBytes() ); IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.store(file, "ANDROID")); assertEquals("安装包公网下载地址未配置", error.getMessage()); } @Test void storeShouldRejectPackageDirOutsideUploadRoot() { LocalUploadProperties properties = buildProperties(); properties.setAppPackageDir("../packages"); LocalAppPackageStorageService service = new LocalAppPackageStorageService(properties); MockMultipartFile file = new MockMultipartFile( "file", "demo.apk", "application/vnd.android.package-archive", "apk-content".getBytes() ); IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.store(file, "ANDROID")); assertEquals("安装包存储目录不合法", error.getMessage()); } @Test void buildPublicUrlShouldAvoidDoubleSlash() { LocalUploadProperties properties = buildProperties(); properties.setPublicBaseUrl("https://api.example.com/"); properties.setPathPrefix("/uploads/"); LocalAppPackageStorageService service = new LocalAppPackageStorageService(properties); String publicUrl = service.buildPublicUrl("app-package/android/2026/04/23/demo.apk"); assertEquals("https://api.example.com/uploads/app-package/android/2026/04/23/demo.apk", publicUrl); } private LocalUploadProperties buildProperties() { LocalUploadProperties properties = new LocalUploadProperties(); properties.setDir(tempDir.toString()); properties.setPathPrefix("/uploads"); properties.setAppPackageDir("app-package"); properties.setPublicBaseUrl("https://api.example.com"); return properties; } } traditionalApp/config/index.js
@@ -1,6 +1,6 @@ export default { // BASE_URL: 'http://192.168.110.188:48080', BASE_URL: 'https://haizhentong.com', BASE_URL: 'http://192.168.110.188:48080', // BASE_URL: 'https://haizhentong.com', uploadUrl: "https://haizhentong.com/api/open/file/upload", imageUrl: 'http://221.182.45.100:11224/images/' }; uploads/app-package/android/2026/04/23/948d062bdc49401ea903ca5c92c56b06.apkBinary files differ