Преглед изворни кода

feat(server): 组织与数字讲师/生产队列 API 及课程集成

bob пре 1 месец
родитељ
комит
9ae3505123
6 измењених фајлова са 1157 додато и 233 уклоњено
  1. 215 12
      package-lock.json
  2. 2 0
      package.json
  3. 325 0
      server/digital-instructors.ts
  4. 302 186
      server/index.ts
  5. 311 0
      server/organizations.ts
  6. 2 35
      server/templates.ts

+ 215 - 12
package-lock.json

@@ -11,9 +11,11 @@
         "@fastify/cors": "^10.0.2",
         "@fastify/multipart": "^9.0.3",
         "@fastify/static": "^8.1.1",
+        "@types/qrcode": "^1.5.6",
         "fastify": "^5.2.1",
         "lucide-react": "^0.468.0",
         "pg": "^8.13.1",
+        "qrcode": "^1.5.4",
         "react": "^19.0.0",
         "react-dom": "^19.0.0",
         "react-router-dom": "^7.1.1",
@@ -1472,7 +1474,6 @@
       "version": "22.20.1",
       "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.1.tgz",
       "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "undici-types": "~6.21.0"
@@ -1490,6 +1491,15 @@
         "pg-types": "^2.2.0"
       }
     },
+    "node_modules/@types/qrcode": {
+      "version": "1.5.6",
+      "resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.6.tgz",
+      "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/react": {
       "version": "19.2.18",
       "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.18.tgz",
@@ -1590,7 +1600,6 @@
       "version": "5.0.1",
       "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
       "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=8"
@@ -1600,7 +1609,6 @@
       "version": "4.3.0",
       "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
       "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "color-convert": "^2.0.1"
@@ -1709,6 +1717,15 @@
         "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
       }
     },
+    "node_modules/camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/caniuse-lite": {
       "version": "1.0.30001809",
       "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
@@ -1779,7 +1796,6 @@
       "version": "2.0.1",
       "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
       "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "color-name": "~1.1.4"
@@ -1792,7 +1808,6 @@
       "version": "1.1.4",
       "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
       "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/concurrently": {
@@ -1891,6 +1906,15 @@
         }
       }
     },
+    "node_modules/decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/depd": {
       "version": "2.0.0",
       "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
@@ -1909,6 +1933,12 @@
         "node": ">=6"
       }
     },
+    "node_modules/dijkstrajs": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+      "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+      "license": "MIT"
+    },
     "node_modules/electron-to-chromium": {
       "version": "1.5.411",
       "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz",
@@ -1920,7 +1950,6 @@
       "version": "8.0.0",
       "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
       "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/esbuild": {
@@ -2143,6 +2172,19 @@
         "node": ">=20"
       }
     },
+    "node_modules/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "license": "MIT",
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/foreground-child": {
       "version": "3.3.1",
       "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -2187,7 +2229,6 @@
       "version": "2.0.5",
       "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
       "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
-      "dev": true,
       "license": "ISC",
       "engines": {
         "node": "6.* || 8.* || >= 10.*"
@@ -2265,7 +2306,6 @@
       "version": "3.0.0",
       "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
       "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=8"
@@ -2387,6 +2427,18 @@
       ],
       "license": "MIT"
     },
+    "node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "license": "MIT",
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/lru-cache": {
       "version": "5.1.1",
       "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2502,12 +2554,57 @@
         "node": ">=14.0.0"
       }
     },
+    "node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "license": "MIT",
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "license": "MIT",
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/package-json-from-dist": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
       "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
       "license": "BlueOak-1.0.0"
     },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/path-key": {
       "version": "3.1.1",
       "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
@@ -2688,6 +2785,15 @@
       "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
       "license": "MIT"
     },
+    "node_modules/pngjs": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz",
+      "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
     "node_modules/postcss": {
       "version": "8.5.26",
       "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz",
@@ -2772,6 +2878,89 @@
       ],
       "license": "MIT"
     },
+    "node_modules/qrcode": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
+      "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+      "license": "MIT",
+      "dependencies": {
+        "dijkstrajs": "^1.0.1",
+        "pngjs": "^5.0.0",
+        "yargs": "^15.3.1"
+      },
+      "bin": {
+        "qrcode": "bin/qrcode"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/qrcode/node_modules/cliui": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
+      "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^6.2.0"
+      }
+    },
+    "node_modules/qrcode/node_modules/wrap-ansi": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+      "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/qrcode/node_modules/y18n": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz",
+      "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+      "license": "ISC"
+    },
+    "node_modules/qrcode/node_modules/yargs": {
+      "version": "15.4.1",
+      "resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz",
+      "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+      "license": "MIT",
+      "dependencies": {
+        "cliui": "^6.0.0",
+        "decamelize": "^1.2.0",
+        "find-up": "^4.1.0",
+        "get-caller-file": "^2.0.1",
+        "require-directory": "^2.1.1",
+        "require-main-filename": "^2.0.0",
+        "set-blocking": "^2.0.0",
+        "string-width": "^4.2.0",
+        "which-module": "^2.0.0",
+        "y18n": "^4.0.0",
+        "yargs-parser": "^18.1.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/qrcode/node_modules/yargs-parser": {
+      "version": "18.1.3",
+      "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz",
+      "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+      "license": "ISC",
+      "dependencies": {
+        "camelcase": "^5.0.0",
+        "decamelize": "^1.2.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/quick-format-unescaped": {
       "version": "4.0.4",
       "resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@@ -2860,7 +3049,6 @@
       "version": "2.1.1",
       "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
       "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=0.10.0"
@@ -2875,6 +3063,12 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/require-main-filename": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz",
+      "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+      "license": "ISC"
+    },
     "node_modules/ret": {
       "version": "0.5.0",
       "resolved": "https://registry.npmmirror.com/ret/-/ret-0.5.0.tgz",
@@ -3039,6 +3233,12 @@
         "semver": "bin/semver.js"
       }
     },
+    "node_modules/set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+      "license": "ISC"
+    },
     "node_modules/set-cookie-parser": {
       "version": "2.7.2",
       "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -3138,7 +3338,6 @@
       "version": "4.2.3",
       "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
       "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "emoji-regex": "^8.0.0",
@@ -3153,7 +3352,6 @@
       "version": "6.0.1",
       "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
       "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "ansi-regex": "^5.0.1"
@@ -3284,7 +3482,6 @@
       "version": "6.21.0",
       "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
       "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/update-browserslist-db": {
@@ -3892,6 +4089,12 @@
         "node": ">= 8"
       }
     },
+    "node_modules/which-module": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
+      "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+      "license": "ISC"
+    },
     "node_modules/wrap-ansi": {
       "version": "7.0.0",
       "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

+ 2 - 0
package.json

@@ -14,9 +14,11 @@
     "@fastify/cors": "^10.0.2",
     "@fastify/multipart": "^9.0.3",
     "@fastify/static": "^8.1.1",
+    "@types/qrcode": "^1.5.6",
     "fastify": "^5.2.1",
     "lucide-react": "^0.468.0",
     "pg": "^8.13.1",
+    "qrcode": "^1.5.4",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
     "react-router-dom": "^7.1.1",

+ 325 - 0
server/digital-instructors.ts

@@ -0,0 +1,325 @@
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
+import type pg from 'pg'
+import { randomUUID } from 'node:crypto'
+import { copyFile, unlink, writeFile } from 'node:fs/promises'
+import { extname, resolve } from 'node:path'
+import { z } from 'zod'
+
+type Dependencies = { pool: pg.Pool; auth: (req: FastifyRequest, reply: FastifyReply) => Promise<unknown>; uploadDir: string }
+const input = z.object({ name: z.string().trim().min(1).max(80), note: z.string().trim().max(1000).default('') })
+const uid = (req: FastifyRequest) => (req as any).user.id as string
+const selected = (req: FastifyRequest) => String(req.headers['x-workspace-id'] || 'personal')
+
+export function registerDigitalInstructors(app: FastifyInstance, { pool, auth, uploadDir }: Dependencies) {
+  async function membership(req: FastifyRequest, organizationId: string) {
+    return (await pool.query(`SELECT m.role,o.is_active FROM organization_members m JOIN organizations o ON o.id=m.organization_id WHERE m.organization_id=$1 AND m.user_id=$2`, [organizationId, uid(req)])).rows[0] as { role: string; is_active: boolean } | undefined
+  }
+  async function access(req: FastifyRequest, row: any) {
+    if ((req as any).user.role === 'ADMIN' && row.organization_id && selected(req) === row.organization_id) return 'MANAGER'
+    if (row.personal_user_id) return row.personal_user_id === uid(req) && selected(req) === 'personal' ? 'OWNER' : null
+    if (selected(req) !== row.organization_id) return null
+    const member = await membership(req, row.organization_id)
+    if (!member) return null
+    return member.role === 'MANAGER' ? 'MANAGER' : row.owner_id === uid(req) ? 'OWNER' : 'VIEWER'
+  }
+  async function get(id: string) { return (await pool.query('SELECT * FROM digital_instructors WHERE id=$1 AND deleted_at IS NULL', [id])).rows[0] }
+  async function files(id: string) { return (await pool.query('SELECT * FROM digital_instructor_files WHERE instructor_id=$1 AND is_active=TRUE ORDER BY kind,sort_order,created_at', [id])).rows }
+  async function dto(row: any) {
+    const fileRows = await files(row.id)
+    const usageCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM courses WHERE digital_instructor_id=$1', [row.id])).rows[0].count)
+    const creator = row.admin_creator_id
+      ? (await pool.query('SELECT contact_name FROM platform_admins WHERE id=$1', [row.admin_creator_id])).rows[0]
+      : (await pool.query('SELECT contact_name FROM users WHERE id=$1', [row.creator_id])).rows[0]
+    const owner = (await pool.query('SELECT contact_name FROM users WHERE id=$1', [row.owner_id])).rows[0]
+    return { id: row.id, name: row.name, note: row.note,
+      organizationId: row.organization_id, personalUserId: row.personal_user_id, ownerId: row.owner_id,
+      ownerName: owner?.contact_name || '未知用户', creatorId: row.admin_creator_id || row.creator_id, creatorName: creator?.contact_name || '未知用户',
+      createdAt: row.created_at, updatedAt: row.updated_at, sourceId: row.source_id, qualityStatus: row.quality_status, usageCount,
+      images: fileRows.filter(f => f.kind === 'IMAGE').map(f => ({ id: f.id, name: f.original_name, url: `/uploads/${f.stored_name}` })),
+      audio: fileRows.filter(f => f.kind === 'AUDIO').map(f => ({ id: f.id, name: f.original_name, url: `/uploads/${f.stored_name}` })),
+      ready: fileRows.some(f => f.kind === 'IMAGE') && fileRows.some(f => f.kind === 'AUDIO') }
+  }
+  async function event(id: string, actor: string, action: string, details: object = {}, client: pg.PoolClient | pg.Pool = pool) {
+    const admin = (await client.query('SELECT 1 FROM platform_admins WHERE id=$1', [actor])).rows[0]
+    await client.query('INSERT INTO digital_instructor_events(id,instructor_id,actor_id,admin_actor_id,action,details) VALUES($1,$2,$3,$4,$5,$6)',
+      [randomUUID(), id, admin ? null : actor, admin ? actor : null, action, JSON.stringify(details)])
+  }
+  app.get('/api/digital-instructors', { preHandler: auth }, async (req, reply) => {
+    const workspace = selected(req)
+    if (workspace !== 'personal' && (req as any).user.role !== 'ADMIN' && !await membership(req, workspace)) return reply.code(403).send({ message: '无权访问该组织资产库' })
+    const rows = workspace === 'personal'
+      ? (await pool.query('SELECT * FROM digital_instructors WHERE personal_user_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC', [uid(req)])).rows
+      : (await pool.query('SELECT * FROM digital_instructors WHERE organization_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC', [workspace])).rows
+    return { instructors: await Promise.all(rows.map(dto)) }
+  })
+  app.get('/api/digital-instructors/:id', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    if (!row || !await access(req, row)) return reply.code(404).send({ message: '讲师不存在' })
+    return { instructor: await dto(row) }
+  })
+  app.get('/api/digital-instructors/:id/usage', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    if (!row || !await access(req, row)) return reply.code(404).send({ message: '讲师不存在' })
+    const { rows } = await pool.query('SELECT id,name,status FROM courses WHERE digital_instructor_id=$1 ORDER BY created_at DESC', [row.id])
+    return { courses: rows }
+  })
+  app.post('/api/digital-instructors', { preHandler: auth }, async (req, reply) => {
+    if ((req as any).user.role === 'ADMIN') return reply.code(403).send({ message: '平台管理员不能创建普通用户资产' })
+    const workspace = selected(req)
+    if (workspace !== 'personal') {
+      const member = await membership(req, workspace)
+      if (!member || !member.is_active) return reply.code(403).send({ message: '无权在该组织创建讲师' })
+    }
+    const fields: Record<string, string> = {}
+    const pending: Array<{ kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = []
+    let committed = false
+    try {
+      for await (const part of req.parts()) {
+        if (part.type === 'field') { fields[part.fieldname] = String(part.value); continue }
+        const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null
+        const mime = part.mimetype.toLowerCase()
+        if (!kind || !(kind === 'IMAGE' ? ['image/jpeg','image/png','image/webp'].includes(mime) : ['audio/wav','audio/x-wav','audio/mpeg','audio/mp4','audio/x-m4a','audio/webm','audio/ogg'].includes(mime))) throw new Error('照片或录音格式不支持')
+        if (kind === 'AUDIO' && pending.some(f => f.kind === 'AUDIO')) throw new Error('每位讲师只能上传一份录音')
+        const buffer = await part.toBuffer()
+        if (!buffer.length) throw new Error('上传文件不能为空')
+        if (buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error(kind === 'IMAGE' ? '单张照片不能超过 20 MB' : '录音不能超过 50 MB')
+        const stored = `digital-instructor-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}`
+        await writeFile(resolve(uploadDir, stored), buffer)
+        pending.push({ kind, original: part.filename.slice(0,255), stored, size: buffer.length, mime })
+      }
+      const parsed = input.safeParse(fields)
+      if (!parsed.success) throw new Error('请填写讲师姓名')
+      const id = randomUUID()
+      const client = await pool.connect()
+      try {
+        await client.query('BEGIN')
+        await client.query(`INSERT INTO digital_instructors(id,organization_id,personal_user_id,owner_id,creator_id,name,note) VALUES($1,$2,$3,$4,$4,$5,$6)`, [id, workspace === 'personal' ? null : workspace, workspace === 'personal' ? uid(req) : null, uid(req), parsed.data.name, parsed.data.note])
+        const coverNewIndex = fields.coverNewIndex === undefined ? -1 : Number(fields.coverNewIndex)
+        for (const [i, f] of pending.entries()) await client.query(`INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [randomUUID(), id, f.kind, f.original, f.stored, f.size, f.mime, f.kind === 'IMAGE' && i === coverNewIndex ? -1 : i])
+        await event(id, uid(req), 'CREATED', {}, client)
+        await client.query('COMMIT')
+        committed = true
+      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+      return reply.code(201).send({ instructor: await dto(await get(id)) })
+    } catch (error: any) {
+      if (!committed) await Promise.all(pending.map(f => unlink(resolve(uploadDir,f.stored)).catch(() => {})))
+      if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请换一个名字' })
+      return reply.code(400).send({ message: (error as Error).message || '创建失败' })
+    }
+  })
+  app.patch('/api/digital-instructors/:id', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    const permission = row && await access(req, row)
+    if (!permission) return reply.code(404).send({ message: '讲师不存在' })
+    if (permission === 'VIEWER') return reply.code(403).send({ message: '只有所有者或组织管理员可以编辑' })
+    const parsed = input.safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '讲师资料不完整' })
+    try {
+      await pool.query('UPDATE digital_instructors SET name=$1,note=$2,updated_at=NOW() WHERE id=$3', [parsed.data.name,parsed.data.note,row.id])
+    } catch (error: any) {
+      if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请换一个名字' })
+      throw error
+    }
+    await event(row.id,uid(req),'UPDATED')
+    return { instructor: await dto(await get(row.id)) }
+  })
+  app.post('/api/digital-instructors/:id/manage', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    const permission = row && await access(req, row)
+    if (!permission) return reply.code(404).send({ message: '讲师不存在' })
+    if (permission === 'VIEWER') return reply.code(403).send({ message: '只有所有者或组织管理员可以编辑' })
+    const fields: Record<string, string> = {}
+    const pending: Array<{ id: string; kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = []
+    let committed = false
+    try {
+      for await (const part of req.parts()) {
+        if (part.type === 'field') { fields[part.fieldname] = String(part.value); continue }
+        const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null
+        const mime = part.mimetype.toLowerCase()
+        if (!kind || !(kind === 'IMAGE' ? ['image/jpeg','image/png','image/webp'].includes(mime) : ['audio/wav','audio/x-wav','audio/mpeg','audio/mp4','audio/x-m4a','audio/webm','audio/ogg'].includes(mime))) throw new Error('照片或录音格式不支持')
+        if (kind === 'AUDIO' && pending.some(f => f.kind === 'AUDIO')) throw new Error('每位讲师只能上传一份录音')
+        const buffer = await part.toBuffer()
+        if (!buffer.length || buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error('文件为空或超过大小限制')
+        const stored = `digital-instructor-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}`
+        await writeFile(resolve(uploadDir,stored),buffer)
+        pending.push({ id: randomUUID(),kind,original:part.filename.slice(0,255),stored,size:buffer.length,mime })
+      }
+      const parsed = input.safeParse(fields)
+      if (!parsed.success) throw new Error('请填写正确的讲师名称和备注')
+      const removeIds = fields.removeImageIds ? JSON.parse(fields.removeImageIds) : []
+      if (!Array.isArray(removeIds) || removeIds.some((id: unknown) => typeof id !== 'string' || !z.string().uuid().safeParse(id).success)) throw new Error('照片移除参数不正确')
+      const ownerId = fields.ownerId || row.owner_id
+      if (ownerId !== row.owner_id && permission !== 'MANAGER') { const forbidden = new Error('只有组织管理员可以更换所有者') as Error & { statusCode?: number }; forbidden.statusCode = 403; throw forbidden }
+      if (ownerId !== row.owner_id && !(await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [row.organization_id,ownerId])).rows[0]) throw new Error('新所有者必须是本组织成员')
+      const client = await pool.connect()
+      try {
+        await client.query('BEGIN')
+        await client.query('SELECT id FROM digital_instructors WHERE id=$1 FOR UPDATE', [row.id])
+        const activeImages = (await client.query("SELECT id FROM digital_instructor_files WHERE instructor_id=$1 AND kind='IMAGE' AND is_active=TRUE ORDER BY sort_order,created_at", [row.id])).rows as { id: string }[]
+        if (removeIds.some((id: string) => !activeImages.some(image => image.id === id))) throw new Error('要移除的照片不存在')
+        const retained = activeImages.filter(image => !removeIds.includes(image.id)).map(image => image.id)
+        const newImages = pending.filter(file => file.kind === 'IMAGE')
+        if (retained.length + newImages.length > 19) throw new Error('最多保留 19 张照片')
+        const coverId = fields.coverImageId || (fields.coverNewIndex !== undefined ? newImages[Number(fields.coverNewIndex)]?.id : '')
+        if (coverId && ![...retained,...newImages.map(file => file.id)].includes(coverId)) throw new Error('封面照片不存在')
+        await client.query('UPDATE digital_instructors SET name=$1,note=$2,owner_id=$3,updated_at=NOW() WHERE id=$4', [parsed.data.name,parsed.data.note,ownerId,row.id])
+        if (removeIds.length) await client.query("UPDATE digital_instructor_files SET is_active=FALSE WHERE instructor_id=$1 AND id=ANY($2::uuid[]) AND kind='IMAGE'", [row.id,removeIds])
+        if (pending.some(file => file.kind === 'AUDIO')) await client.query("UPDATE digital_instructor_files SET is_active=FALSE WHERE instructor_id=$1 AND kind='AUDIO'", [row.id])
+        for (const [index,file] of pending.entries()) await client.query(`INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order)
+          VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [file.id,row.id,file.kind,file.original,file.stored,file.size,file.mime,index+activeImages.length])
+        const order = [...retained,...newImages.map(file => file.id)]
+        if (coverId) order.unshift(...order.splice(order.indexOf(coverId),1))
+        for (const [index,id] of order.entries()) await client.query('UPDATE digital_instructor_files SET sort_order=$1 WHERE id=$2', [index,id])
+        await event(row.id,uid(req),'MANAGED',{ removedImageIds: removeIds, addedImageCount: newImages.length, replacedAudio: pending.some(file => file.kind === 'AUDIO'), ownerChanged: ownerId !== row.owner_id },client)
+        await client.query('COMMIT')
+        committed = true
+      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+      return { instructor: await dto(await get(row.id)) }
+    } catch (error: any) {
+      if (!committed) await Promise.all(pending.map(file => unlink(resolve(uploadDir,file.stored)).catch(() => {})))
+      if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请换一个名字' })
+      return reply.code(error.statusCode || 400).send({ message: error.message || '保存失败' })
+    }
+  })
+  app.patch('/api/digital-instructors/:id/owner', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    if (!row || await access(req,row) !== 'MANAGER') return reply.code(403).send({ message: '只有组织管理员可以更换所有者' })
+    const parsed = z.object({ ownerId: z.string().uuid() }).safeParse(req.body)
+    if (!parsed.success || !(await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [row.organization_id,parsed.data.ownerId])).rows[0]) return reply.code(400).send({ message: '新所有者必须是本组织成员' })
+    await pool.query('UPDATE digital_instructors SET owner_id=$1,updated_at=NOW() WHERE id=$2', [parsed.data.ownerId,row.id])
+    await event(row.id,uid(req),'OWNER_CHANGED',{ from: row.owner_id, to: parsed.data.ownerId })
+    return { instructor: await dto(await get(row.id)) }
+  })
+  app.post('/api/digital-instructors/:id/copy-to-organization', { preHandler: auth }, async (req, reply) => {
+    if ((req as any).user.role === 'ADMIN') return reply.code(403).send({ message: '平台管理员没有个人资产' })
+    const row = await get((req.params as any).id)
+    if (!row || row.personal_user_id !== uid(req) || selected(req) !== 'personal') return reply.code(404).send({ message: '个人讲师不存在' })
+    const parsed = z.object({ organizationId: z.string().uuid() }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '请选择组织' })
+    const member = await membership(req,parsed.data.organizationId)
+    if (!member || !member.is_active) return reply.code(403).send({ message: '您不属于该组织或组织已停用' })
+    const originalFiles = await files(row.id)
+    const copies: Array<{ original: any; stored: string }> = []
+    let committed = false
+    try {
+      for (const f of originalFiles) {
+        const stored = `digital-instructor-${randomUUID()}${extname(f.stored_name)}`
+        await copyFile(resolve(uploadDir,f.stored_name),resolve(uploadDir,stored))
+        copies.push({ original: f, stored })
+      }
+      const id = randomUUID()
+      const client = await pool.connect()
+      try {
+        await client.query('BEGIN')
+        await client.query(`INSERT INTO digital_instructors(id,organization_id,owner_id,creator_id,source_id,name,note) VALUES($1,$2,$3,$3,$4,$5,$6)`, [id,parsed.data.organizationId,uid(req),row.id,row.name,row.note])
+        for (const [i, c] of copies.entries()) await client.query(`INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [randomUUID(),id,c.original.kind,c.original.original_name,c.stored,c.original.size,c.original.mime_type,i])
+        await event(id,uid(req),'COPIED_FROM_PERSONAL',{ sourceId: row.id },client)
+        await client.query('COMMIT')
+        committed = true
+      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+      return reply.code(201).send({ instructor: await dto(await get(id)) })
+    } catch (error: any) {
+      if (!committed) await Promise.all(copies.map(c => unlink(resolve(uploadDir,c.stored)).catch(() => {})))
+      if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请先修改讲师姓名' })
+      return reply.code(500).send({ message: '复制失败,请重试' })
+    }
+  })
+  app.post('/api/digital-instructors/:id/duplicate', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    if (!row || !await access(req, row)) return reply.code(404).send({ message: '讲师不存在' })
+    if (row.organization_id && !(await pool.query('SELECT is_active FROM organizations WHERE id=$1', [row.organization_id])).rows[0]?.is_active) {
+      return reply.code(409).send({ message: '组织已停用,不能复制讲师' })
+    }
+    const originalFiles = await files(row.id)
+    const copies: Array<{ original: any; stored: string }> = []
+    let committed = false
+    try {
+      for (const f of originalFiles) {
+        const stored = `digital-instructor-${randomUUID()}${extname(f.stored_name)}`
+        await copyFile(resolve(uploadDir, f.stored_name), resolve(uploadDir, stored))
+        copies.push({ original: f, stored })
+      }
+      const id = randomUUID()
+      const client = await pool.connect()
+      try {
+        await client.query('BEGIN')
+        const base = row.name.slice(0, 72)
+        let copyName = `${base}(副本)`
+        if (row.organization_id) {
+          await client.query('SELECT id FROM organizations WHERE id=$1 FOR UPDATE', [row.organization_id])
+          for (let number = 2; (await client.query(`SELECT 1 FROM digital_instructors WHERE organization_id=$1 AND deleted_at IS NULL AND lower(btrim(name))=lower(btrim($2)) LIMIT 1`, [row.organization_id,copyName])).rows[0]; number++) {
+            copyName = `${base}(副本${number})`
+          }
+        }
+        const admin = (req as any).user.role === 'ADMIN'
+        await client.query(`INSERT INTO digital_instructors(id,organization_id,personal_user_id,owner_id,creator_id,admin_creator_id,source_id,name,note)
+          VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [id,row.organization_id,row.personal_user_id ? uid(req) : null,admin ? row.owner_id : uid(req),admin ? null : uid(req),admin ? uid(req) : null,row.id,copyName,row.note])
+        for (const [i,c] of copies.entries()) await client.query(`INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order)
+          VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [randomUUID(),id,c.original.kind,c.original.original_name,c.stored,c.original.size,c.original.mime_type,i])
+        await event(id,uid(req),'DUPLICATED',{ sourceId: row.id },client)
+        await client.query('COMMIT')
+        committed = true
+      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+      return reply.code(201).send({ instructor: await dto(await get(id)) })
+    } catch {
+      if (!committed) await Promise.all(copies.map(c => unlink(resolve(uploadDir,c.stored)).catch(() => {})))
+      return reply.code(500).send({ message: '复制失败,请重试' })
+    }
+  })
+  app.post('/api/digital-instructors/:id/files', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    const permission = row && await access(req,row)
+    if (!permission) return reply.code(404).send({ message: '讲师不存在' })
+    if (permission === 'VIEWER') return reply.code(403).send({ message: '只有所有者或组织管理员可以编辑' })
+    const pending: Array<{ kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = []
+    let committed = false
+    try {
+      for await (const part of req.parts()) {
+        if (part.type !== 'file') continue
+        const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null
+        const mime = part.mimetype.toLowerCase()
+        if (!kind || !(kind === 'IMAGE' ? ['image/jpeg','image/png','image/webp'].includes(mime) : ['audio/wav','audio/x-wav','audio/mpeg','audio/mp4','audio/x-m4a','audio/webm','audio/ogg'].includes(mime))) throw new Error('照片或录音格式不支持')
+        if (kind === 'AUDIO' && pending.some(f => f.kind === 'AUDIO')) throw new Error('每位讲师只能上传一份录音')
+        const buffer = await part.toBuffer()
+        if (!buffer.length) throw new Error('文件不能为空')
+        if (buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error(kind === 'IMAGE' ? '单张照片不能超过 20 MB' : '录音不能超过 50 MB')
+        const stored = `digital-instructor-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}`
+        await writeFile(resolve(uploadDir,stored),buffer)
+        pending.push({ kind,original:part.filename.slice(0,255),stored,size:buffer.length,mime })
+      }
+      if (!pending.length) throw new Error('请选择新文件')
+      const client = await pool.connect()
+      try {
+        await client.query('BEGIN')
+        for (const kind of new Set(pending.map(f => f.kind))) await client.query('UPDATE digital_instructor_files SET is_active=FALSE WHERE instructor_id=$1 AND kind=$2', [row.id,kind])
+        for (const [i,f] of pending.entries()) await client.query('INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order) VALUES($1,$2,$3,$4,$5,$6,$7,$8)', [randomUUID(),row.id,f.kind,f.original,f.stored,f.size,f.mime,i])
+        await client.query('UPDATE digital_instructors SET updated_at=NOW() WHERE id=$1', [row.id])
+        await event(row.id,uid(req),'FILES_REPLACED',{ kinds:[...new Set(pending.map(f => f.kind))] },client)
+        await client.query('COMMIT')
+        committed = true
+      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+      return { instructor: await dto(await get(row.id)) }
+    } catch (error) {
+      if (!committed) await Promise.all(pending.map(f => unlink(resolve(uploadDir,f.stored)).catch(() => {})))
+      return reply.code(400).send({ message:(error as Error).message || '上传失败' })
+    }
+  })
+  app.delete('/api/digital-instructors/:id', { preHandler: auth }, async (req, reply) => {
+    const row = await get((req.params as any).id)
+    const permission = row && await access(req,row)
+    if (!permission) return reply.code(404).send({ message: '讲师不存在' })
+    if (row.organization_id && permission !== 'MANAGER') return reply.code(403).send({ message: '只有组织管理员可以删除' })
+    const client = await pool.connect()
+    try {
+      await client.query('BEGIN')
+      await client.query('SELECT id FROM digital_instructors WHERE id=$1 FOR UPDATE', [row.id])
+      const used = (await client.query('SELECT 1 FROM courses WHERE digital_instructor_id=$1 LIMIT 1', [row.id])).rows[0]
+      if (used) { await client.query('ROLLBACK'); return reply.code(409).send({ message: '已有课程使用该讲师,请先为相关课程更换讲师' }) }
+      await event(row.id,uid(req),'DELETED',{ name: row.name },client)
+      await client.query('UPDATE digital_instructors SET deleted_at=NOW(),updated_at=NOW() WHERE id=$1', [row.id])
+      await client.query('COMMIT')
+    } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+    return { ok: true }
+  })
+  return { dto, get, files }
+}

+ 302 - 186
server/index.ts

@@ -13,6 +13,8 @@ import { promisify } from 'node:util'
 import { z } from 'zod'
 import { mapCourse, mapDeliverable, mapInstructor, mapUser, pool } from './db.js'
 import { COURSE_TEMPLATES, populateCourseFromTemplate } from './templates.js'
+import { registerOrganizations } from './organizations.js'
+import { registerDigitalInstructors } from './digital-instructors.js'
 
 const scrypt = promisify(scryptCb)
 const app = Fastify({ logger: true })
@@ -25,39 +27,54 @@ await mkdir(uploadDir, { recursive: true })
 const here = dirname(fileURLToPath(import.meta.url))
 await pool.query(await readFile(join(here, 'schema.sql'), 'utf8'))
 
-// 检查是否存在管理员账号,若不存在则创建默认演示管理员
+// 旧版管理员从 users 迁出。若曾拥有业务数据,停止启动并要求人工处理,避免级联删除。
 async function initDefaultAdmin() {
+  const client = await pool.connect()
   try {
-    const { rows } = await pool.query("SELECT id FROM users WHERE role = 'ADMIN' LIMIT 1")
-    if (rows.length === 0) {
-      const defaultPhone = process.env.ADMIN_PHONE || '18888888888'
-      const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123456'
-      const existing = await pool.query('SELECT id FROM users WHERE phone = $1', [defaultPhone])
-      const salt = randomBytes(16).toString('hex')
-      const key = (await scrypt(defaultPassword, salt, 64)) as Buffer
-      const hash = 'scrypt$' + salt + '$' + key.toString('hex')
-
-      if (existing.rows.length > 0) {
-        await pool.query("UPDATE users SET role = 'ADMIN' WHERE id = $1", [existing.rows[0].id])
-        app.log.info(`Updated existing user ${defaultPhone} to ADMIN role`)
-      } else {
-        const id = randomUUID()
-        await pool.query(
-          "INSERT INTO users(id, phone, password_hash, role, contact_name, organization, bio) VALUES($1, $2, $3, 'ADMIN', $4, $5, $6)",
-          [id, defaultPhone, hash, '系统管理员', '星痕课程工坊管理中心', '默认初始化管理员账号']
-        )
-        app.log.info(`Initialized default admin account for ${defaultPhone}`)
-      }
+    await client.query('BEGIN')
+    const legacy = await client.query("SELECT * FROM users WHERE role = 'ADMIN' FOR UPDATE")
+    for (const row of legacy.rows) {
+      const owned = await client.query(`SELECT
+        EXISTS(SELECT 1 FROM courses WHERE user_id=$1) OR
+        EXISTS(SELECT 1 FROM organization_members WHERE user_id=$1) OR
+        EXISTS(SELECT 1 FROM digital_instructors WHERE owner_id=$1 OR creator_id=$1 OR personal_user_id=$1) AS has_business_data`, [row.id])
+      if (owned.rows[0].has_business_data) throw new Error(`Legacy admin ${row.id} owns business data; manual migration required`)
+      const auditIds = (await client.query('SELECT id FROM audit_events WHERE actor_id=$1', [row.id])).rows.map(r => r.id)
+      const ledgerIds = (await client.query("SELECT id FROM points_ledger WHERE actor_id=$1 AND NOT (owner_type='PERSONAL' AND owner_id=$1)", [row.id])).rows.map(r => r.id)
+      const applicationIds = (await client.query('SELECT id FROM organization_applications WHERE reviewed_by=$1', [row.id])).rows.map(r => r.id)
+      const instructorEventIds = (await client.query('SELECT id FROM digital_instructor_events WHERE actor_id=$1', [row.id])).rows.map(r => r.id)
+      // 外键先释放,再用新的管理员外键恢复历史操作人的身份。
+      await client.query('UPDATE audit_events SET actor_id=NULL WHERE actor_id=$1', [row.id])
+      await client.query('UPDATE points_ledger SET actor_id=NULL WHERE actor_id=$1', [row.id])
+      await client.query('UPDATE organization_applications SET reviewed_by=NULL WHERE reviewed_by=$1', [row.id])
+      await client.query('UPDATE digital_instructor_events SET actor_id=NULL WHERE actor_id=$1', [row.id])
+      await client.query("DELETE FROM points_ledger WHERE owner_type='PERSONAL' AND owner_id=$1", [row.id])
+      await client.query('DELETE FROM users WHERE id=$1', [row.id])
+      await client.query('INSERT INTO platform_admins(id,phone,password_hash,contact_name,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6)',
+        [row.id,row.phone,row.password_hash,row.contact_name,row.created_at,row.updated_at])
+      await client.query('UPDATE audit_events SET admin_actor_id=$1 WHERE id=ANY($2::uuid[])', [row.id,auditIds])
+      await client.query('UPDATE points_ledger SET admin_actor_id=$1 WHERE id=ANY($2::uuid[])', [row.id,ledgerIds])
+      await client.query('UPDATE organization_applications SET reviewed_by_admin=$1 WHERE id=ANY($2::uuid[])', [row.id,applicationIds])
+      await client.query('UPDATE digital_instructor_events SET admin_actor_id=$1 WHERE id=ANY($2::uuid[])', [row.id,instructorEventIds])
     }
-  } catch (err) {
-    app.log.error(err, 'Failed to initialize default admin account')
-  }
+    const count = await client.query('SELECT 1 FROM platform_admins LIMIT 1')
+    if (!count.rows[0]) {
+      const phone = process.env.ADMIN_PHONE
+      const password = process.env.ADMIN_PASSWORD
+      if (!phone || !password) throw new Error('ADMIN_PHONE and ADMIN_PASSWORD are required to create the first platform administrator')
+      const existing = await client.query('SELECT 1 FROM users WHERE phone=$1', [phone])
+      if (existing.rows[0]) throw new Error('ADMIN_PHONE already belongs to an ordinary user')
+      await client.query('INSERT INTO platform_admins(id,phone,password_hash,contact_name) VALUES($1,$2,$3,$4)',
+        [randomUUID(),phone,await hashPassword(password),'系统管理员'])
+    }
+    await client.query('COMMIT')
+  } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
 }
 await initDefaultAdmin()
 
 await app.register(cors, { origin: process.env.NODE_ENV === 'production' ? false : true })
 await app.register(multipart, { limits: { fileSize: maxBytes, files: 20, fields: 30 } })
-await app.register(staticPlugin, { root: uploadDir, prefix: '/uploads/', decorateReply: false })
+await app.register(staticPlugin, { root: uploadDir, serve: false })
 await app.register(staticPlugin, { root: sampleDir, prefix: '/sample/', decorateReply: false })
 
 const sha = (v: string) => createHash('sha256').update(v).digest('hex')
@@ -76,9 +93,9 @@ async function verifyPassword(p: string, stored: string) {
   return a.length === b.length && timingSafeEqual(a, b)
 }
 
-async function session(uid: string) {
+async function session(uid: string, admin = false) {
   const token = randomBytes(32).toString('hex')
-  await pool.query("INSERT INTO sessions(token_hash, user_id, expires_at) VALUES($1, $2, NOW() + INTERVAL '30 days')", [
+  await pool.query(admin ? "INSERT INTO platform_admin_sessions(token_hash, admin_id, expires_at) VALUES($1, $2, NOW() + INTERVAL '30 days')" : "INSERT INTO sessions(token_hash, user_id, expires_at) VALUES($1, $2, NOW() + INTERVAL '30 days')", [
     sha(token),
     uid
   ])
@@ -94,15 +111,25 @@ async function auth(req: FastifyRequest, reply: FastifyReply) {
   )
   if (!rows[0]) return reply.code(401).send({ message: '登录已过期,请重新登录' })
   ;(req as any).user = rows[0]
+  if (rows[0].must_change_password && !['/api/me', '/api/me/password', '/api/auth/logout'].includes(req.url.split('?')[0])) {
+    return reply.code(403).send({ message: '请先修改初始密码' })
+  }
 }
 
 async function adminAuth(req: FastifyRequest, reply: FastifyReply) {
-  await auth(req, reply)
-  if (reply.sent) return
-  const user = (req as any).user
-  if (!user || user.role !== 'ADMIN') {
-    return reply.code(403).send({ message: '权限不足:仅管理员可访问此接口' })
+  const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
+  if (!token) return reply.code(401).send({ message: '请先登录管理后台' })
+  const { rows } = await pool.query('SELECT a.* FROM platform_admin_sessions s JOIN platform_admins a ON a.id=s.admin_id WHERE s.token_hash=$1 AND s.expires_at>NOW()', [sha(token)])
+  if (!rows[0]) return reply.code(401).send({ message: '管理员登录已过期' })
+  ;(req as any).user = { ...rows[0], role: 'ADMIN' }
+}
+async function accountAuth(req: FastifyRequest, reply: FastifyReply) {
+  const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
+  if (token) {
+    const { rows } = await pool.query('SELECT a.* FROM platform_admin_sessions s JOIN platform_admins a ON a.id=s.admin_id WHERE s.token_hash=$1 AND s.expires_at>NOW()', [sha(token)])
+    if (rows[0]) { (req as any).user = { ...rows[0], role: 'ADMIN' }; return }
   }
+  return auth(req, reply)
 }
 
 const userId = (r: any) => r.user.id
@@ -114,6 +141,10 @@ const phonePassword = z.object({
 const fullCourse = async (row: any, includeCreator = false) => {
   const latest = await pool.query('SELECT * FROM courses WHERE id = $1', [row.id])
   row = { ...row, ...(latest.rows[0] || {}) }
+  if (row.organization_id) {
+    const organization = await pool.query('SELECT name FROM organizations WHERE id=$1', [row.organization_id])
+    row.organization_name = organization.rows[0]?.name || ''
+  }
   const [a, i, instructorImages, d, e, progress] = await Promise.all([
     pool.query('SELECT * FROM course_assets WHERE course_id = $1 ORDER BY created_at', [row.id]),
     pool.query('SELECT * FROM instructors WHERE course_id = $1 ORDER BY created_at', [row.id]),
@@ -141,12 +172,23 @@ const fullCourse = async (row: any, includeCreator = false) => {
     const u = await pool.query('SELECT * FROM users WHERE id = $1', [row.user_id])
     creatorUser = u.rows[0] || null
   }
-  const instructors = i.rows.map((instructor) => ({
+  let instructors = i.rows.map((instructor) => ({
     ...instructor,
     image_urls: instructorImages.rows
       .filter((image) => image.instructor_id === instructor.id)
       .map((image) => `/uploads/${image.stored_name}`)
   }))
+  if (row.digital_instructor_id) {
+    const digital = await pool.query('SELECT * FROM digital_instructors WHERE id=$1', [row.digital_instructor_id])
+    if (digital.rows[0]) {
+      const imageRows = await pool.query("SELECT stored_name FROM digital_instructor_files WHERE instructor_id=$1 AND kind='IMAGE' ORDER BY sort_order", [row.digital_instructor_id])
+      instructors = [{ ...digital.rows[0], image_urls: imageRows.rows.map(f => `/uploads/${f.stored_name}`) }]
+    }
+  }
+  if (row.status !== 'DRAFT' && row.digital_instructor_snapshot) {
+    const snapshot = row.digital_instructor_snapshot
+    instructors = [{ ...snapshot, image_urls: (snapshot.images || []).map((image: any) => image.url) }]
+  }
   return mapCourse({ ...row, progress_events: progress.rows }, a.rows, instructors, d.rows, creatorUser, episodesRows)
 }
 
@@ -168,23 +210,130 @@ async function refreshCoursePoints(courseId: string) {
 }
 
 const ownedCourse = async (id: string, uid: string) =>
-  (await pool.query('SELECT * FROM courses WHERE id = $1 AND user_id = $2', [id, uid])).rows[0]
+  (await pool.query(`SELECT c.* FROM courses c WHERE c.id = $1 AND
+    ((c.organization_id IS NULL AND c.user_id=$2) OR (c.organization_id IS NOT NULL AND EXISTS (
+      SELECT 1 FROM organization_members m WHERE m.organization_id=c.organization_id AND m.user_id=$2
+      AND (c.user_id=$2 OR m.role='MANAGER'))))`, [id, uid])).rows[0]
+
+registerOrganizations(app, { pool, auth, adminAuth, hashPassword, mapUser })
+const digitalInstructors = registerDigitalInstructors(app, { pool, auth: accountAuth, uploadDir })
+
+async function courseSnapshot(id: string) {
+  const { rows: [course] } = await pool.query('SELECT * FROM courses WHERE id=$1', [id])
+  if (!course) return null
+  const [episodes, assets, instructors, deliverables] = await Promise.all([
+    pool.query('SELECT * FROM episodes WHERE course_id=$1 ORDER BY episode_number', [id]),
+    pool.query('SELECT id,episode_id,original_name,size,mime_type FROM course_assets WHERE course_id=$1 ORDER BY created_at', [id]),
+    pool.query('SELECT id,name,organization,introduction FROM instructors WHERE course_id=$1 ORDER BY created_at', [id]),
+    pool.query('SELECT id,episode_id,original_name,size,mime_type FROM course_deliverables WHERE course_id=$1 ORDER BY created_at', [id])
+  ])
+  return { course, episodes: episodes.rows, assets: assets.rows, instructors: instructors.rows, deliverables: deliverables.rows }
+}
+
+app.addHook('onRequest', async (req) => {
+  if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method)) return
+  const match = req.url.match(/^\/api\/(?:admin\/)?courses\/([0-9a-f-]{36})(?:\/|\?|$)/i)
+  if (!match) return
+  ;(req as any).auditCourseId = match[1]
+  ;(req as any).auditBefore = await courseSnapshot(match[1])
+})
+
+app.addHook('onSend', async (req, reply, payload) => {
+  const id = (req as any).auditCourseId as string | undefined
+  const before = (req as any).auditBefore
+  if (!id || !before || reply.statusCode >= 400) return payload
+  const authorization = req.headers.authorization?.replace(/^Bearer\s+/i, '')
+  if (!authorization) return payload
+  const actor = await pool.query(`SELECT s.user_id FROM sessions s WHERE s.token_hash=$1 AND s.expires_at>NOW()`, [sha(authorization)])
+  const adminActor = actor.rows[0] ? null : (await pool.query(`SELECT s.admin_id FROM platform_admin_sessions s WHERE s.token_hash=$1 AND s.expires_at>NOW()`, [sha(authorization)])).rows[0]
+  if (!actor.rows[0] && !adminActor) return payload
+  const after = await courseSnapshot(id)
+  await pool.query(`INSERT INTO audit_events(id,organization_id,course_id,actor_id,admin_actor_id,subject_user_id,action,details)
+    VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [randomUUID(), before.course.organization_id, id, actor.rows[0]?.user_id || null,
+    adminActor?.admin_id || null, before.course.user_id, `${req.method} ${req.routeOptions.url}`, JSON.stringify({ before, after })])
+  return payload
+})
+
+app.addHook('preHandler', async (req, reply) => {
+  const match = req.url.match(/^\/api\/courses\/([0-9a-f-]{36})(?:\/|\?|$)/i)
+  if (!match) return
+  const { rows } = await pool.query('SELECT organization_id FROM courses WHERE id=$1', [match[1]])
+  if (!rows[0]) return
+  const selected = req.headers['x-workspace-id']
+  const workspace = typeof selected === 'string' && selected !== 'personal' ? selected : null
+  if (rows[0].organization_id !== workspace) return reply.code(404).send({ message: '请切换到课程所属工作区' })
+})
+
+async function courseOperator(req: FastifyRequest, reply: FastifyReply) {
+  await accountAuth(req, reply)
+  if (reply.sent) return
+  if ((req as any).user.role === 'ADMIN') return
+  const id = (req.params as any).id
+  const { rows } = await pool.query(`SELECT 1 FROM courses c JOIN organization_members m ON m.organization_id=c.organization_id
+    WHERE c.id=$1 AND m.user_id=$2 AND m.role='MANAGER'`, [id, userId(req)])
+  if (!rows[0]) return reply.code(403).send({ message: '没有该课程的组织管理权限' })
+}
+
+app.get('/api/files/:stored', { preHandler: accountAuth }, async (req, reply) => {
+  const stored = (req.params as any).stored as string
+  const digitalFile = await pool.query(`SELECT f.stored_name,d.organization_id,d.personal_user_id FROM digital_instructor_files f JOIN digital_instructors d ON d.id=f.instructor_id WHERE f.stored_name=$1`, [stored])
+  if (digitalFile.rows[0]) {
+    const file = digitalFile.rows[0]
+    const permitted = file.personal_user_id === userId(req) || (file.organization_id && ((req as any).user.role === 'ADMIN' || (await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [file.organization_id,userId(req)])).rows[0]))
+    if (!permitted) return reply.code(403).send({ message: '无权访问该文件' })
+    return reply.sendFile(stored, uploadDir)
+  }
+  const { rows } = await pool.query(`SELECT c.user_id,c.organization_id FROM courses c WHERE
+    EXISTS(SELECT 1 FROM course_assets a WHERE a.course_id=c.id AND a.stored_name=$1)
+    OR EXISTS(SELECT 1 FROM course_deliverables d WHERE d.course_id=c.id AND d.stored_name=$1)
+    OR EXISTS(SELECT 1 FROM instructors i WHERE i.course_id=c.id AND i.image_stored_name=$1)
+    OR EXISTS(SELECT 1 FROM instructor_images ii JOIN instructors i ON i.id=ii.instructor_id WHERE i.course_id=c.id AND ii.stored_name=$1)
+    LIMIT 1`, [stored])
+  const course = rows[0]
+  if (!course) return reply.code(404).send({ message: '文件不存在' })
+  if ((req as any).user.role !== 'ADMIN') {
+    const member = await pool.query(`SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2 AND role='MANAGER'`,
+      [course.organization_id, userId(req)])
+    const ownerAllowed = course.user_id === userId(req) && (!course.organization_id ||
+      (await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [course.organization_id, userId(req)])).rows[0])
+    if (!member.rows[0] && !ownerAllowed) return reply.code(403).send({ message: '无权访问该文件' })
+  }
+  return reply.sendFile(stored, uploadDir)
+})
 
 app.get('/api/health', async () => ({ ok: true }))
 
 // ==================== 认证相关 ====================
 
 app.post('/api/auth/register', async (req, reply) => {
-  const p = phonePassword.safeParse(req.body)
+  const p = phonePassword.extend({
+    contactName: z.string().trim().min(1, '请填写姓名').max(80, '姓名不能超过 80 个字'),
+    inviteToken: z.string().optional()
+  }).safeParse(req.body)
   if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
+  if ((await pool.query('SELECT 1 FROM platform_admins WHERE phone=$1', [p.data.phone])).rows[0]) return reply.code(409).send({ message: '该手机号不可注册普通用户' })
+  let organizationId: string | null = null
+  if (p.data.inviteToken) {
+    const invite = await pool.query('SELECT id FROM organizations WHERE invite_token_hash=$1 AND is_active=TRUE', [sha(p.data.inviteToken)])
+    if (!invite.rows[0]) return reply.code(404).send({ message: '注册链接无效或已停用' })
+    organizationId = invite.rows[0].id
+  }
   try {
     const id = randomUUID()
     const hash = await hashPassword(p.data.password)
-    const { rows } = await pool.query(
-      'INSERT INTO users(id, phone, password_hash, role, points_balance) VALUES($1, $2, $3, $4, 10000) RETURNING *',
-      [id, p.data.phone, hash, 'USER']
-    )
-    return reply.code(201).send({ token: await session(id), user: mapUser(rows[0]) })
+    const client = await pool.connect()
+    let user: any
+    try {
+      await client.query('BEGIN')
+      const { rows } = await client.query(
+        'INSERT INTO users(id, phone, password_hash, role, contact_name, points_balance) VALUES($1, $2, $3, $4, $5, 10000) RETURNING *',
+        [id, p.data.phone, hash, 'USER', p.data.contactName]
+      )
+      user = rows[0]
+      if (organizationId) await client.query('INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)', [organizationId, id])
+      await client.query('COMMIT')
+    } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+    return reply.code(201).send({ token: await session(id), user: mapUser(user) })
   } catch (e: any) {
     if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' })
     throw e
@@ -204,40 +353,37 @@ app.post('/api/auth/login', async (req, reply) => {
 app.post('/api/admin/auth/login', async (req, reply) => {
   const p = phonePassword.safeParse(req.body)
   if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
-  const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone])
+  const { rows } = await pool.query('SELECT * FROM platform_admins WHERE phone = $1', [p.data.phone])
   if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
     return reply.code(401).send({ message: '管理员账号或密码错误' })
   }
-  if (rows[0].role !== 'ADMIN') {
-    return reply.code(403).send({ message: '该账号不是管理员,无权登录后台' })
-  }
-  return { token: await session(rows[0].id), user: mapUser(rows[0]) }
+  return { token: await session(rows[0].id, true), user: mapUser({ ...rows[0], role: 'ADMIN' }) }
 })
 
-app.post('/api/auth/logout', { preHandler: auth }, async (req) => {
+app.post('/api/auth/logout', { preHandler: accountAuth }, async (req) => {
   const token = req.headers.authorization!.replace(/^Bearer\s+/i, '')
   await pool.query('DELETE FROM sessions WHERE token_hash = $1', [sha(token)])
+  await pool.query('DELETE FROM platform_admin_sessions WHERE token_hash = $1', [sha(token)])
   return { ok: true }
 })
 
-app.get('/api/me', { preHandler: auth }, async (req) => ({ user: mapUser((req as any).user) }))
+app.get('/api/me', { preHandler: accountAuth }, async (req) => ({ user: mapUser((req as any).user) }))
 
 app.get('/api/me/points-transactions', { preHandler: auth }, async (req) => {
   const { rows } = await pool.query(
-    `SELECT id, name, COALESCE(actual_points, estimated_points) AS points, completed_at
-     FROM courses
-     WHERE user_id = $1 AND points_charged = TRUE
-     ORDER BY completed_at DESC NULLS LAST, updated_at DESC`,
+    `SELECT l.id,l.course_id,COALESCE(c.name,l.course_name_snapshot) AS course_name,l.amount,l.reason,l.created_at
+     FROM points_ledger l LEFT JOIN courses c ON c.id=l.course_id
+     WHERE l.owner_type='PERSONAL' AND l.owner_id=$1 ORDER BY l.created_at DESC`,
     [userId(req)]
   )
   return {
     balance: Number((req as any).user.points_balance) || 0,
     transactions: rows.map((row) => ({
-      id: `course-${row.id}`,
-      courseId: row.id,
-      courseName: row.name,
-      points: -Math.abs(Number(row.points) || 0),
-      createdAt: row.completed_at
+      id: row.id,
+      courseId: row.course_id,
+      courseName: row.course_name || row.reason,
+      points: Number(row.amount),
+      createdAt: row.created_at
     }))
   }
 })
@@ -245,7 +391,6 @@ app.get('/api/me/points-transactions', { preHandler: auth }, async (req) => {
 app.patch('/api/me', { preHandler: auth }, async (req, reply) => {
   const p = z
     .object({
-      organization: z.string().max(120),
       wechat: z.string().max(80),
       contactName: z.string().max(80),
       bio: z.string().max(500)
@@ -254,8 +399,8 @@ app.patch('/api/me', { preHandler: auth }, async (req, reply) => {
   if (!p.success) return reply.code(400).send({ message: '用户信息格式不正确' })
   const d = p.data
   const { rows } = await pool.query(
-    'UPDATE users SET organization = $1, wechat = $2, contact_name = $3, bio = $4, updated_at = NOW() WHERE id = $5 RETURNING *',
-    [d.organization, d.wechat, d.contactName, d.bio, userId(req)]
+    'UPDATE users SET wechat = $1, contact_name = $2, bio = $3, updated_at = NOW() WHERE id = $4 RETURNING *',
+    [d.wechat, d.contactName, d.bio, userId(req)]
   )
   return { user: mapUser(rows[0]) }
 })
@@ -271,7 +416,7 @@ app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
   if (!(await verifyPassword(p.data.currentPassword, (req as any).user.password_hash))) {
     return reply.code(400).send({ message: '当前密码不正确' })
   }
-  await pool.query('UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [
+  await pool.query('UPDATE users SET password_hash = $1, must_change_password = FALSE, updated_at = NOW() WHERE id = $2', [
     await hashPassword(p.data.newPassword),
     userId(req)
   ])
@@ -283,15 +428,32 @@ app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
 const courseInput = z.object({
   name: z.string().trim().min(1, '请输入课程名称').max(100),
   category: z.string().max(50).default(''),
-  description: z.string().max(500).default('')
+  description: z.string().max(500).default(''),
+  digitalInstructorId: z.string().uuid().nullable().optional()
 })
 
+async function validDigitalInstructor(id: string, organizationId: string | null, personalUserId: string) {
+  const row = await digitalInstructors.get(id)
+  if (!row || row.organization_id !== organizationId || (organizationId === null && row.personal_user_id !== personalUserId)) return false
+  return true
+}
+async function readyDigitalInstructor(id: string) {
+  const files = await digitalInstructors.files(id)
+  return files.some(f => f.kind === 'IMAGE') && files.some(f => f.kind === 'AUDIO')
+}
+
 app.get('/api/course-templates', async () => ({ templates: COURSE_TEMPLATES }))
 
-app.get('/api/courses', { preHandler: auth }, async (req) => {
-  const { rows } = await pool.query('SELECT * FROM courses WHERE user_id = $1 ORDER BY created_at DESC', [
-    userId(req)
-  ])
+app.get('/api/courses', { preHandler: auth }, async (req, reply) => {
+  const workspace = req.headers['x-workspace-id'] as string | undefined
+  if (workspace && workspace !== 'personal') {
+    const member = await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [workspace, userId(req)])
+    if (!member.rows[0]) return reply.code(403).send({ message: '您不属于该组织' })
+  }
+  const organizationWorkspace = workspace && workspace !== 'personal'
+  const { rows } = organizationWorkspace
+    ? await pool.query('SELECT * FROM courses WHERE organization_id=$1 AND user_id=$2 ORDER BY created_at DESC', [workspace, userId(req)])
+    : await pool.query('SELECT * FROM courses WHERE organization_id IS NULL AND user_id=$1 ORDER BY created_at DESC', [userId(req)])
   return { courses: await Promise.all(rows.map((r) => fullCourse(r))) }
 })
 
@@ -300,9 +462,17 @@ app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
   if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请完整填写课程信息' })
   const id = randomUUID()
   const d = p.data
+  const workspace = req.headers['x-workspace-id'] as string | undefined
+  const organizationId = workspace && workspace !== 'personal' ? workspace : null
+  if (organizationId) {
+    const member = await pool.query(`SELECT 1 FROM organization_members m JOIN organizations o ON o.id=m.organization_id
+      WHERE m.organization_id=$1 AND m.user_id=$2 AND o.is_active=TRUE`, [organizationId, userId(req)])
+    if (!member.rows[0]) return reply.code(403).send({ message: '您不属于该组织' })
+  }
+  if (d.digitalInstructorId && !await validDigitalInstructor(d.digitalInstructorId, organizationId, userId(req))) return reply.code(400).send({ message: '请选择本工作区的数字讲师' })
   const { rows } = await pool.query(
-    'INSERT INTO courses(id, user_id, name, category, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
-    [id, userId(req), d.name, d.category, d.description]
+    'INSERT INTO courses(id, user_id, organization_id, name, category, description,digital_instructor_id) VALUES($1, $2, $3, $4, $5, $6,$7) RETURNING *',
+    [id, userId(req), organizationId, d.name, d.category, d.description,d.digitalInstructorId || null]
   )
 
   // 课程创建后默认创建第 1 集内容,确保分集不为空
@@ -314,6 +484,8 @@ app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
   )
 
   await addProgressEvent(id, 'USER', 'COURSE_CREATED', '课程已创建', '课程草稿创建成功。')
+  await pool.query(`INSERT INTO audit_events(id,organization_id,course_id,actor_id,subject_user_id,action,details)
+    VALUES($1,$2,$3,$4,$4,'COURSE_CREATED',$5)`, [randomUUID(), organizationId, id, userId(req), JSON.stringify({ name: d.name })])
 
   return reply.code(201).send({ course: await fullCourse(rows[0]) })
 })
@@ -326,16 +498,25 @@ app.post('/api/courses/template', { preHandler: auth }, async (req, reply) => {
 
   const template = COURSE_TEMPLATES.find((t) => t.id === p.data.templateId) || COURSE_TEMPLATES[0]
   const courseId = randomUUID()
+  const workspace = req.headers['x-workspace-id'] as string | undefined
+  const organizationId = workspace && workspace !== 'personal' ? workspace : null
+  if (organizationId) {
+    const member = await pool.query(`SELECT 1 FROM organization_members m JOIN organizations o ON o.id=m.organization_id
+      WHERE m.organization_id=$1 AND m.user_id=$2 AND o.is_active=TRUE`, [organizationId, userId(req)])
+    if (!member.rows[0]) return reply.code(403).send({ message: '您不属于该组织' })
+  }
 
   const { rows } = await pool.query(
-    'INSERT INTO courses(id, user_id, name, category, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
-    [courseId, userId(req), template.name, template.category, template.description]
+    'INSERT INTO courses(id, user_id, organization_id, name, category, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
+    [courseId, userId(req), organizationId, template.name, template.category, template.description]
   )
 
   await populateCourseFromTemplate(pool, courseId, template.id, uploadDir, sampleDir)
 
   await refreshCoursePoints(courseId)
   await addProgressEvent(courseId, 'USER', 'COURSE_CREATED', '从模板创建课程', '已按模板生成课程内容。')
+  await pool.query(`INSERT INTO audit_events(id,organization_id,course_id,actor_id,subject_user_id,action,details)
+    VALUES($1,$2,$3,$4,$4,'COURSE_CREATED_FROM_TEMPLATE',$5)`, [randomUUID(), organizationId, courseId, userId(req), JSON.stringify({ templateId: template.id })])
 
   return reply.code(201).send({ course: await fullCourse(rows[0]) })
 })
@@ -352,14 +533,16 @@ app.patch('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
   const row = await ownedCourse(id, userId(req))
   if (!row) return reply.code(404).send({ message: '课程不存在' })
 
-  const p = courseInput.pick({ name: true, category: true, description: true }).safeParse(req.body)
+  const p = courseInput.pick({ name: true, category: true, description: true, digitalInstructorId: true }).safeParse(req.body)
   if (!p.success) {
     return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查课程信息' })
   }
 
+  if (p.data.digitalInstructorId !== undefined && row.status !== 'DRAFT') return reply.code(409).send({ message: '只有草稿课程可以更换讲师' })
+  if (p.data.digitalInstructorId && !await validDigitalInstructor(p.data.digitalInstructorId,row.organization_id,userId(req))) return reply.code(400).send({ message: '请选择本工作区的数字讲师' })
   const { rows } = await pool.query(
-    'UPDATE courses SET name = $1, category = $2, description = $3, updated_at = NOW() WHERE id = $4 AND user_id = $5 RETURNING *',
-    [p.data.name, p.data.category, p.data.description, id, userId(req)]
+    'UPDATE courses SET name = $1, category = $2, description = $3, digital_instructor_id=COALESCE($5,digital_instructor_id), updated_at = NOW() WHERE id = $4 RETURNING *',
+    [p.data.name, p.data.category, p.data.description, id,p.data.digitalInstructorId || null]
   )
   await refreshCoursePoints(id)
   await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称、分类或需求说明已更新。')
@@ -375,8 +558,8 @@ app.post('/api/courses/:id/cancel-submission', { preHandler: auth }, async (req,
   }
 
   const { rows } = await pool.query(
-    "UPDATE courses SET status = 'DRAFT', submitted_at = NULL, updated_at = NOW() WHERE id = $1 AND user_id = $2 RETURNING *",
-    [id, userId(req)]
+    "UPDATE courses SET status = 'DRAFT', submitted_at = NULL, updated_at = NOW() WHERE id = $1 RETURNING *",
+    [id]
   )
   await addProgressEvent(id, 'USER', 'SUBMISSION_CANCELLED', '已撤回制作申请', '课程恢复为草稿,可继续修改后重新提交。')
   return { course: await fullCourse(rows[0]) }
@@ -388,7 +571,9 @@ app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
   if (!row) return reply.code(404).send({ message: '课程不存在' })
 
   // 已经等待制作、制作中或者制作完成的课程不能删除
-  if (row.status === 'WAITING_PRODUCTION' || row.status === 'IN_PRODUCTION' || row.status === 'COMPLETED') {
+  const manager = row.organization_id && (await pool.query(`SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2 AND role='MANAGER'`,
+    [row.organization_id, userId(req)])).rows[0]
+  if (!manager && (row.points_charged || row.status === 'WAITING_PRODUCTION' || row.status === 'IN_PRODUCTION' || row.status === 'COMPLETED')) {
     return reply.code(409).send({ message: '课程已进入制作排期或已制作完成,不可删除' })
   }
 
@@ -409,7 +594,8 @@ app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
     if (ins.image_stored_name) instructorStoredNames.add(ins.image_stored_name)
   }
   for (const storedName of instructorStoredNames) {
-    await unlink(resolve(uploadDir, storedName as string)).catch(() => {})
+    const shared = await pool.query('SELECT 1 FROM digital_instructor_files WHERE stored_name=$1', [storedName])
+    if (!shared.rows[0]) await unlink(resolve(uploadDir, storedName as string)).catch(() => {})
   }
 
   // 3. 清理交付成品文件
@@ -424,7 +610,7 @@ app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
   }
 
   // 5. 从数据库中删除课程(级联删除 episodes, course_assets, instructors 等)
-  await pool.query('DELETE FROM courses WHERE id = $1 AND user_id = $2', [id, userId(req)])
+  await pool.query('DELETE FROM courses WHERE id = $1', [id])
 
   return { ok: true, message: '课程已成功删除' }
 })
@@ -473,6 +659,7 @@ app.post('/api/courses/:id/assets', { preHandler: auth }, async (req, reply) =>
     )
   }
 
+  await pool.query('UPDATE courses SET updated_at = NOW() WHERE id = $1', [id])
   return { course: await fullCourse(row) }
 })
 
@@ -486,6 +673,7 @@ app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req,
     [assetId, id]
   )
   if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
+  await pool.query('UPDATE courses SET updated_at = NOW() WHERE id = $1', [id])
   return { course: await fullCourse(row) }
 })
 
@@ -637,85 +825,21 @@ app.delete('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (
   return { course: await fullCourse(row) }
 })
 
-app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply) => {
+app.post('/api/courses/:id/submit', { preHandler: auth }, async (req, reply) => {
   const { id } = req.params as any
   const row = await ownedCourse(id, userId(req))
   if (!row) return reply.code(404).send({ message: '课程不存在' })
-  if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
-  const fields: any = {}
-  const images: any[] = []
-  for await (const part of req.parts()) {
-    if (part.type === 'file') {
-      if (!part.mimetype.startsWith('image/')) return reply.code(400).send({ message: '讲师图片必须是图片格式' })
-      const ext = extname(part.filename).slice(0, 12)
-      const stored = 'instructor-' + randomUUID() + ext
-      await pipeline(part.file, createWriteStream(resolve(uploadDir, stored)))
-      images.push({ original: part.filename, stored, mime: part.mimetype })
-    } else {
-      fields[part.fieldname] = part.value
-    }
-  }
-  const p = z
-    .object({
-      name: z.string().trim().min(1, '请填写讲师姓名').max(80),
-      organization: z.string().max(120).default(''),
-      introduction: z.string().max(1000).default('')
-    })
-    .safeParse(fields)
-  if (!p.success) {
-    await Promise.all(images.map((image) => unlink(resolve(uploadDir, image.stored)).catch(() => {})))
-    return reply.code(400).send({ message: '请填写讲师姓名' })
-  }
-  const d = p.data
-  const instructorId = randomUUID()
-  const image = images[0]
-  const { rows } = await pool.query(
-    'INSERT INTO instructors(id, course_id, name, organization, introduction, image_original_name, image_stored_name, image_mime_type) VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *',
-    [
-      instructorId,
-      id,
-      d.name,
-      d.organization,
-      d.introduction,
-      image?.original || null,
-      image?.stored || null,
-      image?.mime || null
-    ]
-  )
-  for (const [index, uploadedImage] of images.entries()) {
-    await pool.query(
-      'INSERT INTO instructor_images(id, instructor_id, original_name, stored_name, mime_type, sort_order) VALUES($1, $2, $3, $4, $5, $6)',
-      [randomUUID(), instructorId, uploadedImage.original, uploadedImage.stored, uploadedImage.mime, index]
-    )
-  }
-  return reply.code(201).send({ instructor: mapInstructor(rows[0]), course: await fullCourse(row) })
-})
-
-app.delete('/api/courses/:id/instructors/:instructorId', { preHandler: auth }, async (req, reply) => {
-  const { id, instructorId } = req.params as any
-  const row = await ownedCourse(id, userId(req))
-  if (!row) return reply.code(404).send({ message: '课程不存在' })
-  if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
-  const imageRows = await pool.query('SELECT stored_name FROM instructor_images WHERE instructor_id = $1', [instructorId])
-  const { rows } = await pool.query(
-    'DELETE FROM instructors WHERE id = $1 AND course_id = $2 RETURNING image_stored_name',
-    [instructorId, id]
-  )
-  const storedNames = new Set(imageRows.rows.map((image) => image.stored_name))
-  if (rows[0]?.image_stored_name) storedNames.add(rows[0].image_stored_name)
-  await Promise.all([...storedNames].map((storedName) => unlink(resolve(uploadDir, storedName as string)).catch(() => {})))
-  return { course: await fullCourse(row) }
-})
-
-app.post('/api/courses/:id/submit', { preHandler: auth }, async (req, reply) => {
-  const { id } = req.params as any
+  if (!row.digital_instructor_id || !await validDigitalInstructor(row.digital_instructor_id,row.organization_id,userId(req)) || !await readyDigitalInstructor(row.digital_instructor_id)) return reply.code(409).send({ message: '提交前请选择讲师,并补齐照片和录音' })
+  const snapshot = await digitalInstructors.dto(await digitalInstructors.get(row.digital_instructor_id))
   const estimate = await refreshCoursePoints(id)
-  const balance = Number((req as any).user.points_balance) || 0
+  const balance = row.organization_id
+    ? Number((await pool.query('SELECT points_balance FROM organizations WHERE id=$1', [row.organization_id])).rows[0]?.points_balance) || 0
+    : Number((req as any).user.points_balance) || 0
   const estimatedPoints = estimate.points
   if (balance < estimatedPoints) return reply.code(409).send({ message: `积分不足:本课程需要 ${estimatedPoints} 积分,当前余额 ${balance} 积分` })
   const { rows } = await pool.query(
-    "UPDATE courses SET status = 'WAITING_PRODUCTION', submitted_at = NOW(), updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status = 'DRAFT' RETURNING *",
-    [id, userId(req)]
+    "UPDATE courses SET status = 'WAITING_PRODUCTION', digital_instructor_snapshot=$2, submitted_at = NOW(), updated_at = NOW() WHERE id = $1 AND status = 'DRAFT' AND digital_instructor_id=$3 RETURNING *",
+    [id,JSON.stringify(snapshot),row.digital_instructor_id]
   )
   if (!rows[0]) return reply.code(409).send({ message: '课程已经提交或不存在' })
   await addProgressEvent(id, 'USER', 'SUBMITTED', '已提交制作', '课程已进入制作队列。')
@@ -763,7 +887,7 @@ app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => {
   if (query.keyword && query.keyword.trim()) {
     params.push(`%${query.keyword.trim()}%`)
     const idx = params.length
-    conditions.push(`(c.name ILIKE $${idx} OR c.category ILIKE $${idx} OR u.phone ILIKE $${idx} OR u.contact_name ILIKE $${idx} OR u.organization ILIKE $${idx})`)
+    conditions.push(`(c.name ILIKE $${idx} OR c.category ILIKE $${idx} OR u.phone ILIKE $${idx} OR u.contact_name ILIKE $${idx})`)
   }
 
   const whereClause = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
@@ -772,7 +896,6 @@ app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => {
       c.*,
       u.phone AS user_phone,
       u.role AS user_role,
-      u.organization AS user_organization,
       u.wechat AS user_wechat,
       u.contact_name AS user_contact_name,
       u.bio AS user_bio,
@@ -798,14 +921,13 @@ app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => {
 })
 
 // 3. 获取特定课程完整详情(供制作工作台使用)
-app.get('/api/admin/courses/:id', { preHandler: adminAuth }, async (req, reply) => {
+app.get('/api/admin/courses/:id', { preHandler: courseOperator }, async (req, reply) => {
   const { id } = req.params as any
   const sql = `
     SELECT
       c.*,
       u.phone AS user_phone,
       u.role AS user_role,
-      u.organization AS user_organization,
       u.wechat AS user_wechat,
       u.contact_name AS user_contact_name,
       u.bio AS user_bio,
@@ -821,7 +943,7 @@ app.get('/api/admin/courses/:id', { preHandler: adminAuth }, async (req, reply)
 })
 
 // 4. 修改课程状态(例如接单转为 IN_PRODUCTION 或其他流转)
-app.post('/api/admin/courses/:id/status', { preHandler: adminAuth }, async (req, reply) => {
+app.post('/api/admin/courses/:id/status', { preHandler: courseOperator }, async (req, reply) => {
   const { id } = req.params as any
   const p = z.object({
     status: z.enum(['DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED'])
@@ -839,7 +961,7 @@ app.post('/api/admin/courses/:id/status', { preHandler: adminAuth }, async (req,
   return { course: await fullCourse(rows[0], true) }
 })
 
-app.patch('/api/admin/courses/:id/points', { preHandler: adminAuth }, async (req, reply) => {
+app.patch('/api/admin/courses/:id/points', { preHandler: courseOperator }, async (req, reply) => {
   const { id } = req.params as any
   const p = z.object({ points: z.number().int().min(1).max(100000), reason: z.string().max(500).default('根据实际制作需求调整') }).safeParse(req.body)
   if (!p.success) return reply.code(400).send({ message: '请输入有效的积分消耗(至少 1 积分)' })
@@ -853,7 +975,7 @@ app.patch('/api/admin/courses/:id/points', { preHandler: adminAuth }, async (req
 })
 
 // 5. 管理员上传制作交付成品文件
-app.post('/api/admin/courses/:id/deliverables', { preHandler: adminAuth }, async (req, reply) => {
+app.post('/api/admin/courses/:id/deliverables', { preHandler: courseOperator }, async (req, reply) => {
   const { id } = req.params as any
   const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
   if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
@@ -902,7 +1024,7 @@ app.post('/api/admin/courses/:id/deliverables', { preHandler: adminAuth }, async
 })
 
 // 6. 删除已上传的交付成果文件
-app.delete('/api/admin/courses/:id/deliverables/:delivId', { preHandler: adminAuth }, async (req, reply) => {
+app.delete('/api/admin/courses/:id/deliverables/:delivId', { preHandler: courseOperator }, async (req, reply) => {
   const { id, delivId } = req.params as any
   const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
   if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
@@ -916,7 +1038,7 @@ app.delete('/api/admin/courses/:id/deliverables/:delivId', { preHandler: adminAu
 })
 
 // 7. 提交制作成果,结单交付(流转为 COMPLETED)
-app.post('/api/admin/courses/:id/complete', { preHandler: adminAuth }, async (req, reply) => {
+app.post('/api/admin/courses/:id/complete', { preHandler: courseOperator }, async (req, reply) => {
   const { id } = req.params as any
   const p = z.object({
     productionNotes: z.string().max(2000).default('')
@@ -931,8 +1053,14 @@ app.post('/api/admin/courses/:id/complete', { preHandler: adminAuth }, async (re
     if (!current.rows[0]) { await client.query('ROLLBACK'); return reply.code(404).send({ message: '课程不存在' }) }
     const cost = Number(current.rows[0].actual_points ?? current.rows[0].estimated_points) || 100
     if (!current.rows[0].points_charged) {
-      const charged = await client.query('UPDATE users SET points_balance = points_balance - $1, updated_at = NOW() WHERE id = $2 AND points_balance >= $1 RETURNING points_balance', [cost, current.rows[0].user_id])
-      if (!charged.rows[0]) { await client.query('ROLLBACK'); return reply.code(409).send({ message: `用户积分不足,完成制作需要扣除 ${cost} 积分` }) }
+      const organizationId = current.rows[0].organization_id
+      const charged = organizationId
+        ? await client.query('UPDATE organizations SET points_balance=points_balance-$1,updated_at=NOW() WHERE id=$2 AND points_balance >= $1 RETURNING points_balance', [cost, organizationId])
+        : await client.query('UPDATE users SET points_balance=points_balance-$1,updated_at=NOW() WHERE id=$2 AND points_balance >= $1 RETURNING points_balance', [cost, current.rows[0].user_id])
+      if (!charged.rows[0]) { await client.query('ROLLBACK'); return reply.code(409).send({ message: `工作区积分不足,完成制作需要扣除 ${cost} 积分` }) }
+      await client.query(`INSERT INTO points_ledger(id,owner_type,owner_id,course_id,course_name_snapshot,beneficiary_user_id,admin_actor_id,amount,reason)
+        VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [randomUUID(), organizationId ? 'ORGANIZATION' : 'PERSONAL', organizationId || current.rows[0].user_id,
+        id, current.rows[0].name, current.rows[0].user_id, (req as any).user.role === 'ADMIN' ? userId(req) : null, -cost, '课程完成扣费'])
     }
     const updated = await client.query("UPDATE courses SET status = 'COMPLETED', production_notes = $1, completed_at = NOW(), points_charged = TRUE, updated_at = NOW() WHERE id = $2 RETURNING *", [p.data.productionNotes, id])
     rows = updated.rows
@@ -950,12 +1078,13 @@ app.get('/api/admin/users', { preHandler: adminAuth }, async (req) => {
   let where = ''
   if (query.keyword && query.keyword.trim()) {
     params.push(`%${query.keyword.trim()}%`)
-    where = 'WHERE (u.phone ILIKE $1 OR u.contact_name ILIKE $1 OR u.organization ILIKE $1 OR u.wechat ILIKE $1)'
+    where = 'WHERE (u.phone ILIKE $1 OR u.contact_name ILIKE $1 OR u.wechat ILIKE $1)'
   }
 
   const sql = `
     SELECT
       u.*,
+      (SELECT string_agg(o.name, '、') FROM organization_members m JOIN organizations o ON o.id=m.organization_id WHERE m.user_id=u.id) AS organization_names,
       COUNT(c.id)::int AS courses_count,
       COUNT(c.id) FILTER (WHERE c.status = 'WAITING_PRODUCTION')::int AS waiting_courses_count,
       COUNT(c.id) FILTER (WHERE c.status = 'IN_PRODUCTION')::int AS in_production_courses_count,
@@ -969,6 +1098,7 @@ app.get('/api/admin/users', { preHandler: adminAuth }, async (req) => {
   const { rows } = await pool.query(sql, params)
   const users = rows.map((r) => ({
     ...mapUser(r),
+    organization: r.organization_names || '',
     coursesCount: r.courses_count || 0,
     waitingCoursesCount: r.waiting_courses_count || 0,
     inProductionCoursesCount: r.in_production_courses_count || 0,
@@ -982,18 +1112,18 @@ app.post('/api/admin/users', { preHandler: adminAuth }, async (req, reply) => {
   const p = z.object({
     phone: z.string().regex(/^1\d{10}$/, '请输入正确的手机号码'),
     password: z.string().min(6, '密码至少 6 位').max(72),
-    contactName: z.string().trim().max(50, '姓名不能超过 50 个字').optional().default(''),
-    organization: z.string().trim().max(100, '单位不能超过 100 个字').optional().default('')
+    contactName: z.string().trim().max(50, '姓名不能超过 50 个字').optional().default('')
   }).safeParse(req.body)
   if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
+  if ((await pool.query('SELECT 1 FROM platform_admins WHERE phone=$1', [p.data.phone])).rows[0]) return reply.code(409).send({ message: '该手机号属于平台管理员' })
 
   try {
     const id = randomUUID()
     const passwordHash = await hashPassword(p.data.password)
     const { rows } = await pool.query(
-      `INSERT INTO users(id, phone, password_hash, role, contact_name, organization, points_balance)
-       VALUES($1, $2, $3, 'USER', $4, $5, 10000) RETURNING *`,
-      [id, p.data.phone, passwordHash, p.data.contactName, p.data.organization]
+      `INSERT INTO users(id, phone, password_hash, role, contact_name, points_balance)
+       VALUES($1, $2, $3, 'USER', $4, 10000) RETURNING *`,
+      [id, p.data.phone, passwordHash, p.data.contactName]
     )
     return reply.code(201).send({ user: mapUser(rows[0]) })
   } catch (e: any) {
@@ -1032,34 +1162,20 @@ app.patch('/api/admin/users/:id/password', { preHandler: adminAuth }, async (req
   }
 })
 
-// 11. 修改用户角色(赋予或撤销管理员权限)
+// 平台管理员与普通用户永不互转。
 app.patch('/api/admin/users/:id/role', { preHandler: adminAuth }, async (req, reply) => {
-  const { id } = req.params as any
-  const currentAdmin = (req as any).user
-  const p = z.object({
-    role: z.enum(['USER', 'ADMIN'])
-  }).safeParse(req.body)
-  if (!p.success) return reply.code(400).send({ message: '角色类型不正确' })
-
-  if (id === currentAdmin.id && p.data.role !== 'ADMIN') {
-    return reply.code(400).send({ message: '不能撤销当前登录账号的管理员权限' })
-  }
-
-  const { rows } = await pool.query(
-    'UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
-    [p.data.role, id]
-  )
-  if (!rows[0]) return reply.code(404).send({ message: '用户不存在' })
-  return { user: mapUser(rows[0]) }
+  return reply.code(403).send({ message: '平台管理员与普通用户不能互相转化' })
 })
 
 // ==================== 静态托管与错误处理 ====================
 
 if (process.env.NODE_ENV === 'production') {
   const dist = resolve('./dist')
-  await app.register(staticPlugin, { root: dist, prefix: '/', wildcard: false })
+  await app.register(staticPlugin, { root: dist, prefix: '/', wildcard: false, decorateReply: false })
   app.setNotFoundHandler((req, reply) =>
-    req.url.startsWith('/api/') ? reply.code(404).send({ message: '接口不存在' }) : reply.sendFile('index.html')
+    req.url.startsWith('/api/') || req.url.startsWith('/uploads/')
+      ? reply.code(404).send({ message: '接口或文件不存在' })
+      : reply.sendFile('index.html', dist)
   )
 }
 

+ 311 - 0
server/organizations.ts

@@ -0,0 +1,311 @@
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
+import type pg from 'pg'
+import { randomBytes, randomUUID, createHash } from 'node:crypto'
+import { z } from 'zod'
+
+type Pool = pg.Pool
+type Deps = {
+  pool: Pool
+  auth: (req: FastifyRequest, reply: FastifyReply) => Promise<unknown>
+  adminAuth: (req: FastifyRequest, reply: FastifyReply) => Promise<unknown>
+  hashPassword: (password: string) => Promise<string>
+  mapUser: (row: any) => any
+}
+const sha = (value: string) => createHash('sha256').update(value).digest('hex')
+const uid = (req: FastifyRequest) => (req as any).user.id as string
+const params = (req: FastifyRequest) => req.params as Record<string, string>
+const json = (value: unknown) => JSON.stringify(value)
+
+export function registerOrganizations(app: FastifyInstance, { pool, auth, adminAuth, hashPassword, mapUser }: Deps) {
+  const membership = async (organizationId: string, userId: string) =>
+    (await pool.query('SELECT role FROM organization_members WHERE organization_id = $1 AND user_id = $2', [organizationId, userId])).rows[0] as { role: 'MEMBER' | 'MANAGER' } | undefined
+  const org = async (id: string) => (await pool.query('SELECT * FROM organizations WHERE id = $1', [id])).rows[0]
+  const manager = async (req: FastifyRequest, reply: FastifyReply, organizationId: string) => {
+    const member = await membership(organizationId, uid(req))
+    if (member?.role !== 'MANAGER') {
+      reply.code(403).send({ message: '没有该组织的管理权限' })
+      return false
+    }
+    return true
+  }
+  const event = (organizationId: string | null, actorId: string, action: string, details: unknown, subjectUserId: string | null = null) =>
+    pool.query(`INSERT INTO audit_events(id, organization_id, actor_id, admin_actor_id, subject_user_id, action, details)
+      VALUES($1,$2,$3,$4,$5,$6,$7)`, [randomUUID(), organizationId, ['ORGANIZATION_CREATED','ORGANIZATION_UPDATED','MEMBER_ROLE_SET','MEMBER_REMOVED','MEMBER_APPROVED','MEMBER_REJECTED','ORGANIZATION_POINTS_ADJUSTED'].includes(action) ? null : actorId,
+      ['ORGANIZATION_CREATED','ORGANIZATION_UPDATED','MEMBER_ROLE_SET','MEMBER_REMOVED','MEMBER_APPROVED','MEMBER_REJECTED','ORGANIZATION_POINTS_ADJUSTED'].includes(action) ? actorId : null,
+      subjectUserId, action, json(details)])
+
+  app.get('/api/me/organizations', { preHandler: auth }, async (req) => {
+    const { rows } = await pool.query(`SELECT o.id, o.slug, o.name, o.is_active, o.points_balance, m.role
+      FROM organization_members m JOIN organizations o ON o.id = m.organization_id
+      WHERE m.user_id = $1 ORDER BY o.name`, [uid(req)])
+    return { organizations: rows.map((r) => ({ id: r.id, slug: r.slug, name: r.name, isActive: r.is_active, pointsBalance: r.points_balance, role: r.role })) }
+  })
+
+  app.get('/api/organizations/:slug', { preHandler: auth }, async (req) => {
+    const { rows } = await pool.query('SELECT id, name, is_active FROM organizations WHERE slug = $1', [params(req).slug])
+    return { organization: rows[0] ? { id: rows[0].id, name: rows[0].name, isActive: rows[0].is_active } : null }
+  })
+
+  app.post('/api/organizations/:slug/apply', { preHandler: auth }, async (req, reply) => {
+    const { rows } = await pool.query('SELECT id FROM organizations WHERE slug = $1 AND is_active=TRUE', [params(req).slug])
+    if (!rows[0]) return reply.code(404).send({ message: '组织不存在' })
+    const organizationId = rows[0].id
+    if (await membership(organizationId, uid(req))) return reply.code(409).send({ message: '您已经是该组织成员' })
+    await pool.query(`INSERT INTO organization_applications(id, organization_id, user_id, status)
+      VALUES($1,$2,$3,'PENDING') ON CONFLICT (organization_id,user_id)
+      DO UPDATE SET status = 'PENDING', reviewed_by = NULL, reviewed_at = NULL, created_at = NOW()`,
+      [randomUUID(), organizationId, uid(req)])
+    await event(organizationId, uid(req), 'MEMBERSHIP_APPLIED', {})
+    return { ok: true }
+  })
+
+  app.post('/api/organizations/join/:token', { preHandler: auth }, async (req, reply) => {
+    const { rows } = await pool.query('SELECT id FROM organizations WHERE invite_token_hash = $1 AND is_active=TRUE', [sha(params(req).token)])
+    if (!rows[0]) return reply.code(404).send({ message: '注册链接无效或已停用' })
+    await pool.query(`INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)
+      ON CONFLICT (organization_id,user_id) DO NOTHING`, [rows[0].id, uid(req)])
+    await pool.query(`UPDATE organization_applications SET status='APPROVED',reviewed_at=NOW()
+      WHERE organization_id=$1 AND user_id=$2 AND status='PENDING'`, [rows[0].id, uid(req)])
+    await event(rows[0].id, uid(req), 'INVITE_JOINED', {})
+    return { organizationId: rows[0].id }
+  })
+
+  app.get('/api/organizations/:id/content', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    const member = await membership(id, uid(req))
+    if (!member) return reply.code(403).send({ message: '请先加入组织' })
+    const organization = await org(id)
+    if (!organization) return reply.code(404).send({ message: '组织不存在' })
+    const [members, courses] = await Promise.all([
+      pool.query(`SELECT u.id, u.contact_name, m.role FROM organization_members m
+        JOIN users u ON u.id=m.user_id WHERE m.organization_id=$1 ORDER BY m.joined_at`, [id]),
+      pool.query(`SELECT c.id,c.user_id,c.name,c.category,c.status,c.created_at,c.updated_at,c.submitted_at,c.completed_at,
+        u.contact_name AS creator_name FROM courses c JOIN users u ON u.id=c.user_id
+        WHERE c.organization_id=$1 ORDER BY c.created_at DESC`, [id])
+    ])
+    return { organization: { id, slug: organization.slug, name: organization.name, pointsBalance: organization.points_balance, role: member.role }, members: members.rows, courses: courses.rows }
+  })
+
+  app.get('/api/organizations/:id/manage', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    if (!(await manager(req, reply, id))) return
+    const [members, applications, audit, ledger] = await Promise.all([
+      pool.query(`SELECT u.id,u.phone,u.contact_name,m.role,m.joined_at FROM organization_members m
+        JOIN users u ON u.id=m.user_id WHERE m.organization_id=$1 ORDER BY m.joined_at DESC`, [id]),
+      pool.query(`SELECT a.id,a.status,a.created_at,u.id AS user_id,u.phone,u.contact_name
+        FROM organization_applications a JOIN users u ON u.id=a.user_id
+        WHERE a.organization_id=$1 ORDER BY a.created_at DESC`, [id]),
+      pool.query(`SELECT a.*,u.contact_name AS actor_name FROM audit_events a LEFT JOIN users u ON u.id=a.actor_id
+        WHERE a.organization_id=$1 ORDER BY a.created_at DESC`, [id]),
+      pool.query(`SELECT l.*,u.contact_name AS beneficiary_name FROM points_ledger l
+        LEFT JOIN users u ON u.id=l.beneficiary_user_id
+        WHERE l.owner_type='ORGANIZATION' AND l.owner_id=$1 ORDER BY l.created_at DESC`, [id])
+    ])
+    const organization = await org(id)
+    return { members: members.rows, applications: applications.rows, audit: audit.rows, ledger: ledger.rows,
+      inviteActive: Boolean(organization?.invite_token_hash),
+      inviteToken: organization?.invite_token || null }
+  })
+
+  app.post('/api/organizations/:id/applications/:applicationId/review', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    if (!(await manager(req, reply, id))) return
+    const parsed = z.object({ approve: z.boolean() }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '审批参数不正确' })
+    const client = await pool.connect()
+    try {
+      await client.query('BEGIN')
+      const application = await client.query(`UPDATE organization_applications SET status=$1,reviewed_by=$2,reviewed_at=NOW()
+        WHERE id=$3 AND organization_id=$4 AND status='PENDING' RETURNING user_id`,
+        [parsed.data.approve ? 'APPROVED' : 'REJECTED', uid(req), params(req).applicationId, id])
+      if (!application.rows[0]) { await client.query('ROLLBACK'); return reply.code(404).send({ message: '待审批申请不存在' }) }
+      if (parsed.data.approve) await client.query(`INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)
+        ON CONFLICT DO NOTHING`, [id, application.rows[0].user_id])
+      await client.query('COMMIT')
+      await event(id, uid(req), parsed.data.approve ? 'MEMBER_APPROVED' : 'MEMBER_REJECTED',
+        { applicationId: params(req).applicationId, before: 'PENDING', after: parsed.data.approve ? 'APPROVED' : 'REJECTED' }, application.rows[0].user_id)
+      return { ok: true }
+    } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+  })
+
+  app.post('/api/organizations/:id/users', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    if (!(await manager(req, reply, id))) return
+    if (!(await org(id))?.is_active) return reply.code(409).send({ message: '组织已停用,不能创建新成员' })
+    const parsed = z.object({ phone: z.string().regex(/^1\d{10}$/), contactName: z.string().trim().min(1).max(80) }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '请填写正确的手机号和姓名' })
+    const client = await pool.connect()
+    try {
+      await client.query('BEGIN')
+      const userId = randomUUID()
+      const created = await client.query(`INSERT INTO users(id,phone,password_hash,role,contact_name,points_balance,must_change_password)
+        VALUES($1,$2,$3,'USER',$4,10000,TRUE) RETURNING *`,
+        [userId, parsed.data.phone, await hashPassword('888888'), parsed.data.contactName])
+      await client.query('INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)', [id, userId])
+      await client.query('COMMIT')
+      await event(id, uid(req), 'USER_CREATED', { phone: parsed.data.phone, contactName: parsed.data.contactName }, userId)
+      return reply.code(201).send({ user: mapUser(created.rows[0]), initialPassword: '888888' })
+    } catch (error: any) {
+      await client.query('ROLLBACK')
+      if (error.code === '23505') return reply.code(409).send({ message: '手机号已注册,请让用户通过组织链接加入' })
+      throw error
+    } finally { client.release() }
+  })
+
+  app.patch('/api/organizations/:id/members/:userId', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    if (!(await manager(req, reply, id))) return
+    const parsed = z.object({ contactName: z.string().trim().min(1, '请填写姓名').max(80, '姓名不能超过 80 个字') }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: parsed.error.issues[0].message })
+    const member = await membership(id, params(req).userId)
+    if (!member) return reply.code(404).send({ message: '组织成员不存在' })
+    const { rows } = await pool.query('UPDATE users SET contact_name=$1,updated_at=NOW() WHERE id=$2 RETURNING *', [parsed.data.contactName, params(req).userId])
+    await event(id, uid(req), 'MEMBER_NAME_UPDATED', { contactName: parsed.data.contactName }, params(req).userId)
+    return { user: mapUser(rows[0]) }
+  })
+
+  app.post('/api/organizations/:id/invite', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    if (!(await manager(req, reply, id))) return
+    if (!(await org(id))?.is_active) return reply.code(409).send({ message: '组织已停用,不能生成注册链接' })
+    const token = randomBytes(24).toString('hex')
+    await pool.query('UPDATE organizations SET invite_token_hash=$1,invite_token=$2,updated_at=NOW() WHERE id=$3', [sha(token), token, id])
+    await event(id, uid(req), 'INVITE_ROTATED', {})
+    return { token }
+  })
+  app.delete('/api/organizations/:id/invite', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    if (!(await manager(req, reply, id))) return
+    await pool.query('UPDATE organizations SET invite_token_hash=NULL,invite_token=NULL,updated_at=NOW() WHERE id=$1', [id])
+    await event(id, uid(req), 'INVITE_DISABLED', {})
+    return { ok: true }
+  })
+
+  app.get('/api/organizations/:id/points', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    const member = await membership(id, uid(req))
+    if (!member) return reply.code(403).send({ message: '请先加入组织' })
+    const { rows: [organization] } = await pool.query('SELECT points_balance FROM organizations WHERE id=$1', [id])
+    const { rows } = await pool.query(`SELECT l.id,l.course_id,COALESCE(c.name,l.course_name_snapshot) AS course_name,l.amount,l.reason,l.created_at
+      FROM points_ledger l LEFT JOIN courses c ON c.id=l.course_id
+      WHERE l.owner_type='ORGANIZATION' AND l.owner_id=$1 ${member.role === 'MANAGER' ? '' : 'AND l.beneficiary_user_id=$2'}
+      ORDER BY l.created_at DESC`, member.role === 'MANAGER' ? [id] : [id, uid(req)])
+    return { balance: organization?.points_balance ?? 0, transactions: rows }
+  })
+
+  app.get('/api/admin/organizations', { preHandler: adminAuth }, async () => {
+    const { rows } = await pool.query(`SELECT o.*,COUNT(m.user_id)::int AS members_count FROM organizations o
+      LEFT JOIN organization_members m ON m.organization_id=o.id GROUP BY o.id ORDER BY o.created_at DESC`)
+    return { organizations: rows }
+  })
+  app.post('/api/admin/organizations', { preHandler: adminAuth }, async (req, reply) => {
+    const parsed = z.object({ name: z.string().trim().min(1).max(120), slug: z.string().regex(/^[a-z0-9-]{3,80}$/) }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '请填写名称和英文网址标识' })
+    try {
+      const { rows } = await pool.query('INSERT INTO organizations(id,name,slug) VALUES($1,$2,$3) RETURNING *',
+        [randomUUID(), parsed.data.name, parsed.data.slug])
+      await event(rows[0].id, uid(req), 'ORGANIZATION_CREATED', { name: rows[0].name })
+      return reply.code(201).send({ organization: rows[0] })
+    } catch (error: any) {
+      if (error.code === '23505') return reply.code(409).send({ message: '网址标识已存在' })
+      throw error
+    }
+  })
+  app.get('/api/admin/organizations/:id', { preHandler: adminAuth }, async (req, reply) => {
+    const id = params(req).id
+    const organization = await org(id)
+    if (!organization) return reply.code(404).send({ message: '组织不存在' })
+    const [members, applications, ledger, audit, courses] = await Promise.all([
+      pool.query(`SELECT u.id,u.phone,u.contact_name,m.role FROM organization_members m JOIN users u ON u.id=m.user_id
+        WHERE m.organization_id=$1 ORDER BY m.joined_at DESC`, [id]),
+      pool.query(`SELECT a.id,a.status,u.id AS user_id,u.phone,u.contact_name FROM organization_applications a
+        JOIN users u ON u.id=a.user_id WHERE a.organization_id=$1 ORDER BY a.created_at DESC`, [id]),
+      pool.query(`SELECT * FROM points_ledger WHERE owner_type='ORGANIZATION' AND owner_id=$1 ORDER BY created_at DESC`, [id]),
+      pool.query(`SELECT a.*,u.contact_name AS actor_name FROM audit_events a LEFT JOIN users u ON u.id=a.actor_id
+        WHERE a.organization_id=$1 ORDER BY a.created_at DESC`, [id]),
+      pool.query(`SELECT c.id,c.name,c.status,c.created_at,c.updated_at,u.contact_name AS creator_name FROM courses c JOIN users u ON u.id=c.user_id
+        WHERE c.organization_id=$1 ORDER BY c.created_at DESC`, [id])
+    ])
+    return { organization, members: members.rows, applications: applications.rows, ledger: ledger.rows, audit: audit.rows, courses: courses.rows }
+  })
+  app.patch('/api/admin/organizations/:id', { preHandler: adminAuth }, async (req, reply) => {
+    const parsed = z.object({
+      name: z.string().trim().min(1).max(120).optional(),
+      slug: z.string().trim().regex(/^[a-z0-9-]{3,80}$/, '网址标识需为3-80位小写字母、数字或连字符').optional(),
+      isActive: z.boolean().optional()
+    }).refine((v) => v.name !== undefined || v.slug !== undefined || v.isActive !== undefined).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: parsed.error.issues[0]?.message || '组织参数不正确' })
+    const old = await org(params(req).id)
+    try {
+      const { rows } = await pool.query(
+        'UPDATE organizations SET name=COALESCE($1,name),slug=COALESCE($2,slug),is_active=COALESCE($3,is_active),updated_at=NOW() WHERE id=$4 RETURNING *',
+        [parsed.data.name ?? null, parsed.data.slug ?? null, parsed.data.isActive ?? null, params(req).id]
+      )
+      if (!rows[0]) return reply.code(404).send({ message: '组织不存在' })
+      await event(rows[0].id, uid(req), 'ORGANIZATION_UPDATED', {
+        before: { name: old?.name, slug: old?.slug, isActive: old?.is_active },
+        after: { name: rows[0].name, slug: rows[0].slug, isActive: rows[0].is_active }
+      })
+      return { organization: rows[0] }
+    } catch (error: any) {
+      if (error.code === '23505') return reply.code(409).send({ message: '网址标识已存在' })
+      throw error
+    }
+  })
+  app.put('/api/admin/organizations/:id/members/:userId', { preHandler: adminAuth }, async (req, reply) => {
+    const parsed = z.object({ role: z.enum(['MEMBER', 'MANAGER']) }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '成员角色不正确' })
+    const id = params(req).id
+    const previous = await membership(id, params(req).userId)
+    const { rows } = await pool.query(`INSERT INTO organization_members(organization_id,user_id,role) VALUES($1,$2,$3)
+      ON CONFLICT (organization_id,user_id) DO UPDATE SET role=EXCLUDED.role RETURNING *`, [id, params(req).userId, parsed.data.role])
+    await pool.query(`UPDATE organization_applications SET status='APPROVED',reviewed_by_admin=$1,reviewed_at=NOW()
+      WHERE organization_id=$2 AND user_id=$3 AND status='PENDING'`, [uid(req), id, params(req).userId])
+    await event(id, uid(req), 'MEMBER_ROLE_SET', { before: previous?.role || null, after: parsed.data.role }, params(req).userId)
+    return { member: rows[0] }
+  })
+  app.delete('/api/admin/organizations/:id/members/:userId', { preHandler: adminAuth }, async (req) => {
+    const id = params(req).id
+    await pool.query('DELETE FROM organization_members WHERE organization_id=$1 AND user_id=$2', [id, params(req).userId])
+    await event(id, uid(req), 'MEMBER_REMOVED', {}, params(req).userId)
+    return { ok: true }
+  })
+  app.post('/api/admin/organizations/:id/applications/:applicationId/review', { preHandler: adminAuth }, async (req, reply) => {
+    const parsed = z.object({ approve: z.boolean() }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '审批参数不正确' })
+    const id = params(req).id
+    const client = await pool.connect()
+    try {
+      await client.query('BEGIN')
+      const { rows } = await client.query(`UPDATE organization_applications SET status=$1,reviewed_by_admin=$2,reviewed_at=NOW()
+        WHERE id=$3 AND organization_id=$4 AND status='PENDING' RETURNING user_id`,
+        [parsed.data.approve ? 'APPROVED' : 'REJECTED', uid(req), params(req).applicationId, id])
+      if (!rows[0]) { await client.query('ROLLBACK'); return reply.code(404).send({ message: '待审批申请不存在' }) }
+      if (parsed.data.approve) await client.query('INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2) ON CONFLICT DO NOTHING', [id, rows[0].user_id])
+      await client.query('COMMIT')
+      await event(id, uid(req), parsed.data.approve ? 'MEMBER_APPROVED' : 'MEMBER_REJECTED',
+        { applicationId: params(req).applicationId, before: 'PENDING', after: parsed.data.approve ? 'APPROVED' : 'REJECTED' }, rows[0].user_id)
+      return { ok: true }
+    } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+  })
+  app.post('/api/admin/organizations/:id/points', { preHandler: adminAuth }, async (req, reply) => {
+    const parsed = z.object({ amount: z.number().int().refine((n) => n !== 0), reason: z.string().trim().min(1).max(200) }).safeParse(req.body)
+    if (!parsed.success) return reply.code(400).send({ message: '请填写非零积分和操作原因' })
+    const id = params(req).id
+    const client = await pool.connect()
+    try {
+      await client.query('BEGIN')
+      const { rows } = await client.query(`UPDATE organizations SET points_balance=points_balance+$1,updated_at=NOW()
+        WHERE id=$2 AND points_balance+$1>=0 RETURNING points_balance`, [parsed.data.amount, id])
+      if (!rows[0]) { await client.query('ROLLBACK'); return reply.code(409).send({ message: '组织不存在或余额不足' }) }
+      await client.query(`INSERT INTO points_ledger(id,owner_type,owner_id,admin_actor_id,amount,reason)
+        VALUES($1,'ORGANIZATION',$2,$3,$4,$5)`, [randomUUID(), id, uid(req), parsed.data.amount, parsed.data.reason])
+      await client.query('COMMIT')
+      await event(id, uid(req), 'ORGANIZATION_POINTS_ADJUSTED', {
+        amount: parsed.data.amount, reason: parsed.data.reason,
+        before: rows[0].points_balance - parsed.data.amount, after: rows[0].points_balance
+      })
+      return { balance: rows[0].points_balance }
+    } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+  })
+}

+ 2 - 35
server/templates.ts

@@ -10,7 +10,7 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
     category: '党政专题',
     tags: ['精品党课', 'AI数字人', '1080P高清', 'PPT同步论证'],
     badge: '官方推荐示范',
-    summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字人讲师近景与全景运镜、精准语速停顿控制。',
+    summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字讲师近景与全景运镜、精准语速停顿控制。',
     description: `【制作目标】PPT配合讲稿,完成一支可用于网络党课学习的横版课程视频。
 【形式结构】(课程包装片头—片名)—讲师讲述—PPT论证—讲师回场—章节转场—继续讲述—总结—片尾。
 【运镜与节奏】全片约 30% 为讲师中近景/全景,约 70% 为全屏课件;单页 PPT 驻留 10—45 秒;讲师近景口型眼神对齐,现代演播室虚化背景。
@@ -205,40 +205,7 @@ export async function populateCourseFromTemplate(
 ) {
   const template = COURSE_TEMPLATES.find((t) => t.id === templateId) || COURSE_TEMPLATES[0]
 
-  // 1. 填充讲师
-  for (const inst of template.instructors) {
-    let storedImageName: string | null = null
-    let origImageName: string | null = null
-    let mimeType: string | null = null
-
-    if (inst.sampleImageFile) {
-      try {
-        const sourcePath = resolve(sampleDir, inst.sampleImageFile)
-        storedImageName = `instructor-${randomUUID()}.jpg`
-        const destPath = resolve(uploadDir, storedImageName)
-        await copyFile(sourcePath, destPath)
-        origImageName = inst.sampleImageFile
-        mimeType = 'image/jpeg'
-      } catch (err) {
-        console.error('Failed to copy sample instructor image:', err)
-      }
-    }
-
-    await pool.query(
-      `INSERT INTO instructors(id, course_id, name, organization, introduction, image_original_name, image_stored_name, image_mime_type)
-       VALUES($1, $2, $3, $4, $5, $6, $7, $8)`,
-      [
-        randomUUID(),
-        courseId,
-        inst.name,
-        inst.organization,
-        inst.introduction,
-        origImageName,
-        storedImageName,
-        mimeType
-      ]
-    )
-  }
+  // 示例课程不自动生成缺少录音的数字讲师;用户在工作区选择已配置资产。
 
   // 2. 填充预设分集 (Episodes)
   const episodeMap = new Map<number, string>() // episodeNumber -> episodeId