Compare commits
29 Commits
0e248db1bf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fd81ffbf2 | ||
|
|
cf39ea2b0c | ||
|
|
560f40ae8a | ||
|
|
838c0afc0b | ||
|
|
0666b8683e | ||
|
|
428b6292ea | ||
|
|
cd7feec38a | ||
|
|
01a4580faf | ||
|
|
649af715e2 | ||
|
|
3571cdf2ee | ||
|
|
099ed03be7 | ||
|
|
f62ed97e2b | ||
|
|
760902f53c | ||
|
|
998771bc11 | ||
|
|
576ba9b2fe | ||
|
|
b7b312e69a | ||
|
|
865be73d06 | ||
|
|
ed4b22a564 | ||
|
|
2da43cf027 | ||
|
|
200c86c924 | ||
|
|
e8058d1240 | ||
|
|
6dabc2eab6 | ||
|
|
8609d61f82 | ||
|
|
f01a228026 | ||
|
|
a6e5fbbb24 | ||
|
|
21dc3ea56f | ||
|
|
fdf17ecf85 | ||
|
|
a45aa5a6c7 | ||
|
|
c394e9153e |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,3 +22,7 @@ dist/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local deployment files
|
||||
docker-compose.yml
|
||||
docker-compose.override.yml
|
||||
|
||||
2
backend
2
backend
Submodule backend updated: ade60bdbaa...405000ded5
29
docker-compose.example.yml
Normal file
29
docker-compose.example.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: memberapi_ose_tw
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000/healthz >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
networks:
|
||||
- postgres
|
||||
- ose-cache
|
||||
- nginx
|
||||
|
||||
networks:
|
||||
postgres:
|
||||
external: true
|
||||
ose-cache:
|
||||
external: true
|
||||
nginx:
|
||||
external: true
|
||||
@@ -1,4 +1,4 @@
|
||||
# member.ose.tw 架構總覽(Keycloak 版)
|
||||
# member-platform 架構總覽(Keycloak 版)
|
||||
|
||||
## 核心模型
|
||||
- 業務層:`companies -> sites`
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
## 4) roles
|
||||
- `id` UUID PK default `gen_random_uuid()`
|
||||
- `role_key` TEXT NOT NULL UNIQUE
|
||||
- `role_code` TEXT NOT NULL(語意代碼,建議格式:`<system>:<module>:<action>`,例如 `mkt:marketing_card:edit`)
|
||||
- `system_id` UUID NOT NULL FK -> `systems(id)` ON DELETE CASCADE
|
||||
- `name` TEXT NOT NULL
|
||||
- `description` TEXT
|
||||
@@ -41,6 +42,7 @@
|
||||
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||
- UNIQUE(`system_id`, `name`)
|
||||
- UNIQUE(`system_id`, `role_code`)
|
||||
|
||||
## 5) site_roles
|
||||
- `id` UUID PK default `gen_random_uuid()`
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
6. `POST /internal/users/upsert-by-sub`
|
||||
7. `GET /internal/users/{user_sub}/roles`
|
||||
8. `POST /internal/provider/users/ensure`
|
||||
9. `POST /internal/idp/users/ensure`(舊路徑相容,不建議新串接使用)
|
||||
10. `POST /internal/keycloak/users/ensure`(舊路徑相容,不建議新串接使用)
|
||||
|
||||
## 角色聚合回應(`GET /internal/users/{user_sub}/roles`)
|
||||
```json
|
||||
@@ -38,6 +36,7 @@
|
||||
"system_key": "SY20260402X0001",
|
||||
"system_name": "Marketing",
|
||||
"role_key": "RL20260402X0002",
|
||||
"role_code": "mkt:marketing_card:edit",
|
||||
"role_name": "campaign_edit"
|
||||
}
|
||||
]
|
||||
@@ -47,3 +46,4 @@
|
||||
## 注意事項
|
||||
- 不提供 user direct role 寫入 API。
|
||||
- User 最終角色由 `user_sites` + `site_roles` 推導。
|
||||
- `role_key` 是唯一識別鍵;業務語意解析請使用 `role_code`。
|
||||
|
||||
@@ -7,28 +7,16 @@ psql "$DATABASE_URL" -f scripts/init_schema.sql
|
||||
```
|
||||
- DB schema 檔案:[backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql)
|
||||
|
||||
如果你是 macOS 本機沒裝 `psql`,改用:
|
||||
## 2) 啟動後端
|
||||
本地開發使用 `.env.development`:
|
||||
```bash
|
||||
cd backend
|
||||
./.venv/bin/python - <<'PY'
|
||||
import psycopg
|
||||
from pathlib import Path
|
||||
sql = Path('scripts/migrate_provider_columns.sql').read_text()
|
||||
with psycopg.connect(
|
||||
host='127.0.0.1',
|
||||
port=54321,
|
||||
dbname='member.ose.tw',
|
||||
user='member_ose',
|
||||
password='你的DB密碼'
|
||||
) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
print('provider column migration done')
|
||||
PY
|
||||
# edit .env.development directly
|
||||
```
|
||||
- 欄位改名 migration:[backend/scripts/migrate_provider_columns.sql](../backend/scripts/migrate_provider_columns.sql)
|
||||
|
||||
## 2) 啟動後端
|
||||
本機開發固定使用 `backend/.env.development`。
|
||||
|
||||
再啟動:
|
||||
```bash
|
||||
cd backend
|
||||
./scripts/start_dev.sh
|
||||
@@ -42,6 +30,8 @@ cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
- 本地開發固定使用 `frontend/.env.development`。
|
||||
- production build 讀取 `frontend/.env.production`。
|
||||
- 專案路徑:[frontend](../frontend)
|
||||
|
||||
## 4) 必要環境變數([backend/.env.development](../backend/.env.development))
|
||||
@@ -71,7 +61,7 @@ npm run dev
|
||||
1. `GET http://127.0.0.1:8000/healthz` 應為 200。
|
||||
2. 前端按「前往 Keycloak 登入」應可成功導轉與回跳。
|
||||
3. `GET /me` 登入後應有資料。
|
||||
4. 非 admin 群組帳號打 `/admin/*` 應為 403。
|
||||
4. 非 admin realm role 帳號打 `/admin/*` 應為 403。
|
||||
5. `POST /admin/sync/from-provider?force=true` 可手動觸發全量補齊同步。
|
||||
6. 列表 API 不會自動同步 IdP(避免高負載),需手動按同步按鈕或呼叫同步 API。
|
||||
|
||||
|
||||
109
docs/VPS_DEPLOY_RUNBOOK.md
Normal file
109
docs/VPS_DEPLOY_RUNBOOK.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# VPS Deploy Runbook
|
||||
|
||||
## 1) 拉整合層 + 子模組
|
||||
```bash
|
||||
cd /opt
|
||||
git clone --recurse-submodules http://127.0.0.1:8888/member/member-platform.git
|
||||
cd member-platform
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
## 2) 後端部署(Docker)
|
||||
```bash
|
||||
cd /opt/member-platform/backend
|
||||
cp .env.production .env
|
||||
```
|
||||
編輯 `.env`(DB、Keycloak、Realm Roles、Cache)。
|
||||
|
||||
首次建表:
|
||||
```bash
|
||||
psql "postgresql://<user>:<pass>@<host>:<port>/<db>" -f scripts/init_schema.sql
|
||||
```
|
||||
|
||||
啟動:
|
||||
```bash
|
||||
docker build -t memberapi-backend:latest .
|
||||
docker rm -f memberapi-backend 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name memberapi-backend \
|
||||
--restart unless-stopped \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
--env-file .env \
|
||||
memberapi-backend:latest
|
||||
```
|
||||
|
||||
檢查:
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/healthz
|
||||
docker logs -f memberapi-backend
|
||||
```
|
||||
|
||||
### 用 docker compose(建議)
|
||||
Compose 檔案:[docker-compose.example.yml](../docker-compose.example.yml)
|
||||
|
||||
啟動:
|
||||
```bash
|
||||
cd /opt/member-platform
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
檢查:
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f backend
|
||||
```
|
||||
|
||||
停止:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 3) 前端部署(Nginx)
|
||||
```bash
|
||||
cd /opt/member-platform/frontend
|
||||
```
|
||||
production build 會自動讀取 `.env.production`,請先確認設定:
|
||||
```env
|
||||
VITE_API_BASE_URL=https://memberapi.ose.tw
|
||||
```
|
||||
|
||||
Build:
|
||||
```bash
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
Nginx root 指向 `frontend/dist`,並加 SPA rewrite:
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
|
||||
## 4) 更新流程
|
||||
```bash
|
||||
cd /opt/member-platform
|
||||
git pull
|
||||
git submodule update --init --recursive --remote
|
||||
```
|
||||
|
||||
後端更新:
|
||||
```bash
|
||||
cd backend
|
||||
docker build -t memberapi-backend:latest .
|
||||
docker rm -f memberapi-backend
|
||||
docker run -d --name memberapi-backend --restart unless-stopped -p 127.0.0.1:8000:8000 --env-file .env memberapi-backend:latest
|
||||
```
|
||||
|
||||
前端更新:
|
||||
```bash
|
||||
cd ../frontend
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 5) 建議網域
|
||||
- Frontend: `member.ose.tw`
|
||||
- API: `memberapi.ose.tw`(反代 `127.0.0.1:8000`)
|
||||
81
docs/directus/directus_key_autogen.sql
Normal file
81
docs/directus/directus_key_autogen.sql
Normal file
@@ -0,0 +1,81 @@
|
||||
-- Directus key auto-generation triggers
|
||||
-- Target tables: companies, sites, users, roles
|
||||
-- Key format: PREFIX + yyyymmdd + 'X' + 4 digits
|
||||
-- Example: CP20260404X1234
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.directus_autogen_entity_key()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_column_name text := TG_ARGV[0];
|
||||
v_prefix text := TG_ARGV[1];
|
||||
v_current_value text;
|
||||
v_candidate text;
|
||||
v_exists boolean;
|
||||
v_attempt int;
|
||||
v_day text;
|
||||
v_suffix text;
|
||||
BEGIN
|
||||
v_current_value := to_jsonb(NEW) ->> v_column_name;
|
||||
IF v_current_value IS NOT NULL AND btrim(v_current_value) <> '' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
v_day := to_char(clock_timestamp(), 'YYYYMMDD');
|
||||
|
||||
FOR v_attempt IN 0..9999 LOOP
|
||||
v_suffix := lpad((((extract(epoch FROM clock_timestamp()) * 1000)::bigint + v_attempt) % 10000)::text, 4, '0');
|
||||
v_candidate := v_prefix || v_day || 'X' || v_suffix;
|
||||
|
||||
EXECUTE format(
|
||||
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I = $1)',
|
||||
TG_TABLE_SCHEMA,
|
||||
TG_TABLE_NAME,
|
||||
v_column_name
|
||||
) INTO v_exists USING v_candidate;
|
||||
|
||||
IF NOT v_exists THEN
|
||||
NEW := jsonb_populate_record(NEW, jsonb_build_object(v_column_name, v_candidate));
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
RAISE EXCEPTION 'Failed to generate unique key for %.% (column=%)', TG_TABLE_SCHEMA, TG_TABLE_NAME, v_column_name;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_companies_key_autogen ON public.companies;
|
||||
CREATE TRIGGER trg_companies_key_autogen
|
||||
BEFORE INSERT ON public.companies
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'CP');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_sites_key_autogen ON public.sites;
|
||||
CREATE TRIGGER trg_sites_key_autogen
|
||||
BEFORE INSERT ON public.sites
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'ST');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_users_key_autogen ON public.users;
|
||||
CREATE TRIGGER trg_users_key_autogen
|
||||
BEFORE INSERT ON public.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'UE');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_roles_key_autogen ON public.roles;
|
||||
CREATE TRIGGER trg_roles_key_autogen
|
||||
BEFORE INSERT ON public.roles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'RL');
|
||||
|
||||
-- cleanup old trigger names to avoid duplicates from previous scripts
|
||||
DROP TRIGGER IF EXISTS trg_companies_company_key_autogen ON public.companies;
|
||||
DROP TRIGGER IF EXISTS trg_sites_site_key_autogen ON public.sites;
|
||||
DROP TRIGGER IF EXISTS trg_users_user_key_autogen ON public.users;
|
||||
DROP TRIGGER IF EXISTS trg_systems_system_key_autogen ON public.systems;
|
||||
DROP TRIGGER IF EXISTS trg_roles_role_key_autogen ON public.roles;
|
||||
|
||||
COMMIT;
|
||||
3657
docs/directus/member-schema.directus.json
Normal file
3657
docs/directus/member-schema.directus.json
Normal file
File diff suppressed because it is too large
Load Diff
3656
docs/directus/member-schema.directus.uuid.json
Normal file
3656
docs/directus/member-schema.directus.uuid.json
Normal file
File diff suppressed because it is too large
Load Diff
3702
docs/directus/member-schema.directus.uuid.keys.json
Normal file
3702
docs/directus/member-schema.directus.uuid.keys.json
Normal file
File diff suppressed because it is too large
Load Diff
182
docs/directus/member-schema.snapshot.json
Normal file
182
docs/directus/member-schema.snapshot.json
Normal file
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"version": 1,
|
||||
"directus": "11.0.0",
|
||||
"vendor": "postgres",
|
||||
"collections": [
|
||||
{ "collection": "users", "meta": null, "schema": { "name": "users" } },
|
||||
{ "collection": "companies", "meta": null, "schema": { "name": "companies" } },
|
||||
{ "collection": "sites", "meta": null, "schema": { "name": "sites" } },
|
||||
{ "collection": "systems", "meta": null, "schema": { "name": "systems" } },
|
||||
{ "collection": "roles", "meta": null, "schema": { "name": "roles" } },
|
||||
{ "collection": "site_roles", "meta": null, "schema": { "name": "site_roles" } },
|
||||
{ "collection": "user_sites", "meta": null, "schema": { "name": "user_sites" } },
|
||||
{ "collection": "auth_sync_state", "meta": null, "schema": { "name": "auth_sync_state" } },
|
||||
{ "collection": "api_clients", "meta": null, "schema": { "name": "api_clients" } }
|
||||
],
|
||||
"fields": [
|
||||
{ "collection": "users", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "has_auto_increment": false, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "users", "field": "user_sub", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||
{ "collection": "users", "field": "provider_user_id", "type": "string", "meta": null, "schema": { "is_nullable": true, "is_unique": true } },
|
||||
{ "collection": "users", "field": "username", "type": "string", "meta": null, "schema": { "is_nullable": true, "is_unique": true } },
|
||||
{ "collection": "users", "field": "email", "type": "string", "meta": null, "schema": { "is_nullable": true, "is_unique": true } },
|
||||
{ "collection": "users", "field": "display_name", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "users", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||
{ "collection": "users", "field": "is_active", "type": "boolean", "meta": null, "schema": { "is_nullable": false, "default_value": true } },
|
||||
{ "collection": "users", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "users", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "companies", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "companies", "field": "company_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||
{ "collection": "companies", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "companies", "field": "provider_group_id", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "companies", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||
{ "collection": "companies", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "companies", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "sites", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "sites", "field": "site_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||
{ "collection": "sites", "field": "company_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "companies", "foreign_key_column": "id" } },
|
||||
{ "collection": "sites", "field": "display_name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "sites", "field": "domain", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "sites", "field": "provider_group_id", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "sites", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||
{ "collection": "sites", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "sites", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "systems", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "systems", "field": "system_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||
{ "collection": "systems", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "systems", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||
{ "collection": "systems", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "systems", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "roles", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "roles", "field": "role_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||
{ "collection": "roles", "field": "role_code", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "roles", "field": "system_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "systems", "foreign_key_column": "id" } },
|
||||
{ "collection": "roles", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "roles", "field": "description", "type": "text", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "roles", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||
{ "collection": "roles", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "roles", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "site_roles", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "site_roles", "field": "site_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "sites", "foreign_key_column": "id" } },
|
||||
{ "collection": "site_roles", "field": "role_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "roles", "foreign_key_column": "id" } },
|
||||
{ "collection": "site_roles", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "user_sites", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "user_sites", "field": "user_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "users", "foreign_key_column": "id" } },
|
||||
{ "collection": "user_sites", "field": "site_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "sites", "foreign_key_column": "id" } },
|
||||
{ "collection": "user_sites", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "user_sites", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "auth_sync_state", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "auth_sync_state", "field": "entity_type", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "auth_sync_state", "field": "entity_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "auth_sync_state", "field": "last_synced_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "auth_sync_state", "field": "source_version", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "auth_sync_state", "field": "last_error", "type": "text", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "auth_sync_state", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
|
||||
{ "collection": "api_clients", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||
{ "collection": "api_clients", "field": "client_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||
{ "collection": "api_clients", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "api_clients", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||
{ "collection": "api_clients", "field": "api_key_hash", "type": "text", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "api_clients", "field": "allowed_origins", "type": "json", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "api_clients", "field": "allowed_ips", "type": "json", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "api_clients", "field": "allowed_paths", "type": "json", "meta": null, "schema": { "is_nullable": false } },
|
||||
{ "collection": "api_clients", "field": "rate_limit_per_min", "type": "integer", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "api_clients", "field": "expires_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "api_clients", "field": "last_used_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": true } },
|
||||
{ "collection": "api_clients", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||
{ "collection": "api_clients", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } }
|
||||
],
|
||||
"relations": [
|
||||
{
|
||||
"collection": "sites",
|
||||
"field": "company_id",
|
||||
"related_collection": "companies",
|
||||
"schema": {
|
||||
"table": "sites",
|
||||
"column": "company_id",
|
||||
"foreign_key_table": "companies",
|
||||
"foreign_key_column": "id",
|
||||
"on_update": "NO ACTION",
|
||||
"on_delete": "CASCADE"
|
||||
},
|
||||
"meta": null
|
||||
},
|
||||
{
|
||||
"collection": "roles",
|
||||
"field": "system_id",
|
||||
"related_collection": "systems",
|
||||
"schema": {
|
||||
"table": "roles",
|
||||
"column": "system_id",
|
||||
"foreign_key_table": "systems",
|
||||
"foreign_key_column": "id",
|
||||
"on_update": "NO ACTION",
|
||||
"on_delete": "CASCADE"
|
||||
},
|
||||
"meta": null
|
||||
},
|
||||
{
|
||||
"collection": "site_roles",
|
||||
"field": "site_id",
|
||||
"related_collection": "sites",
|
||||
"schema": {
|
||||
"table": "site_roles",
|
||||
"column": "site_id",
|
||||
"foreign_key_table": "sites",
|
||||
"foreign_key_column": "id",
|
||||
"on_update": "NO ACTION",
|
||||
"on_delete": "CASCADE"
|
||||
},
|
||||
"meta": null
|
||||
},
|
||||
{
|
||||
"collection": "site_roles",
|
||||
"field": "role_id",
|
||||
"related_collection": "roles",
|
||||
"schema": {
|
||||
"table": "site_roles",
|
||||
"column": "role_id",
|
||||
"foreign_key_table": "roles",
|
||||
"foreign_key_column": "id",
|
||||
"on_update": "NO ACTION",
|
||||
"on_delete": "CASCADE"
|
||||
},
|
||||
"meta": null
|
||||
},
|
||||
{
|
||||
"collection": "user_sites",
|
||||
"field": "user_id",
|
||||
"related_collection": "users",
|
||||
"schema": {
|
||||
"table": "user_sites",
|
||||
"column": "user_id",
|
||||
"foreign_key_table": "users",
|
||||
"foreign_key_column": "id",
|
||||
"on_update": "NO ACTION",
|
||||
"on_delete": "CASCADE"
|
||||
},
|
||||
"meta": null
|
||||
},
|
||||
{
|
||||
"collection": "user_sites",
|
||||
"field": "site_id",
|
||||
"related_collection": "sites",
|
||||
"schema": {
|
||||
"table": "user_sites",
|
||||
"column": "site_id",
|
||||
"foreign_key_table": "sites",
|
||||
"foreign_key_column": "id",
|
||||
"on_update": "NO ACTION",
|
||||
"on_delete": "CASCADE"
|
||||
},
|
||||
"meta": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# member.ose.tw 文件入口(新架構)
|
||||
# member-platform 文件入口(新架構)
|
||||
|
||||
## 閱讀順序
|
||||
1. [docs/ARCHITECTURE.md](./ARCHITECTURE.md)
|
||||
@@ -8,6 +8,7 @@
|
||||
5. [docs/FRONTEND_HANDOFF.md](./FRONTEND_HANDOFF.md)
|
||||
6. [docs/INTERNAL_API_HANDOFF.md](./INTERNAL_API_HANDOFF.md)
|
||||
7. [docs/LOCAL_DEV_RUNBOOK.md](./LOCAL_DEV_RUNBOOK.md)
|
||||
8. [docs/VPS_DEPLOY_RUNBOOK.md](./VPS_DEPLOY_RUNBOOK.md)
|
||||
|
||||
## 交辦順序(建議)
|
||||
1. 先看 [ARCHITECTURE.md](./ARCHITECTURE.md) 鎖定資料模型與權限模型。
|
||||
@@ -30,10 +31,21 @@
|
||||
- DB SQL:[backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql)
|
||||
|
||||
## Repo 結構(已拆分)
|
||||
- 整合層(本 repo):`member.ose.tw`(docs / 部署 / 整合)
|
||||
- 整合層(本 repo):`member-platform`(docs / 部署 / 整合)
|
||||
- 後端子模組:[backend](../backend)(submodule: `../member-backend`)
|
||||
- 前端子模組:[frontend](../frontend)(submodule: `../member-frontend`)
|
||||
|
||||
## 開發工作目錄(防呆)
|
||||
- 只在 `member-platform` 內開發與提交。
|
||||
- 後端只改 `member-platform/backend`。
|
||||
- 前端只改 `member-platform/frontend`。
|
||||
- 根目錄外的 `member-backend` / `member-frontend` 若有另一份 clone,視為非主要工作副本,避免混用。
|
||||
|
||||
## 提交順序(固定)
|
||||
1. 先在 `member-platform/backend` 或 `member-platform/frontend` 各自 commit / push。
|
||||
2. 再回 `member-platform` 根目錄,提交子模組版本指標變更(submodule pointer)並 push。
|
||||
3. 部署端只需要更新 `member-platform`,再執行 `git submodule update --init --recursive`。
|
||||
|
||||
## 文件邊界
|
||||
- 本輪只保留可開發、可交辦、可驗收文件。
|
||||
- 最終規格白皮書延後到專案完成後再產出。
|
||||
|
||||
2
frontend
2
frontend
Submodule frontend updated: cf54146606...ed63eaffc6
44
scripts/push-backend.sh
Executable file
44
scripts/push-backend.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "用法: scripts/push-backend.sh \"後端 commit 訊息\" [根目錄 commit 訊息]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUB_MSG="$1"
|
||||
ROOT_MSG="${2:-chore: bump backend submodule}"
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SUB_DIR="$ROOT_DIR/backend"
|
||||
|
||||
current_branch="$(git -C "$SUB_DIR" branch --show-current)"
|
||||
if [ -z "$current_branch" ]; then
|
||||
echo "backend 目前是 detached HEAD,請先執行: cd backend && git checkout master"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$current_branch" != "master" ]; then
|
||||
echo "backend 目前分支是 $current_branch,建議先切到 master 再執行。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$(git -C "$SUB_DIR" status --porcelain)" ]; then
|
||||
git -C "$SUB_DIR" add -A
|
||||
git -C "$SUB_DIR" commit -m "$SUB_MSG"
|
||||
else
|
||||
echo "backend 無本地變更,略過 backend commit。"
|
||||
fi
|
||||
|
||||
git -C "$SUB_DIR" push origin master
|
||||
|
||||
git -C "$ROOT_DIR" add backend
|
||||
if ! git -C "$ROOT_DIR" diff --cached --quiet; then
|
||||
git -C "$ROOT_DIR" commit -m "$ROOT_MSG"
|
||||
else
|
||||
echo "根目錄無 submodule pointer 變更,略過根目錄 commit。"
|
||||
fi
|
||||
|
||||
git -C "$ROOT_DIR" push origin master
|
||||
|
||||
echo "完成: backend 與根目錄已 push。"
|
||||
44
scripts/push-frontend.sh
Executable file
44
scripts/push-frontend.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "用法: scripts/push-frontend.sh \"前端 commit 訊息\" [根目錄 commit 訊息]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUB_MSG="$1"
|
||||
ROOT_MSG="${2:-chore: bump frontend submodule}"
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SUB_DIR="$ROOT_DIR/frontend"
|
||||
|
||||
current_branch="$(git -C "$SUB_DIR" branch --show-current)"
|
||||
if [ -z "$current_branch" ]; then
|
||||
echo "frontend 目前是 detached HEAD,請先執行: cd frontend && git checkout master"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$current_branch" != "master" ]; then
|
||||
echo "frontend 目前分支是 $current_branch,建議先切到 master 再執行。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$(git -C "$SUB_DIR" status --porcelain)" ]; then
|
||||
git -C "$SUB_DIR" add -A
|
||||
git -C "$SUB_DIR" commit -m "$SUB_MSG"
|
||||
else
|
||||
echo "frontend 無本地變更,略過 frontend commit。"
|
||||
fi
|
||||
|
||||
git -C "$SUB_DIR" push origin master
|
||||
|
||||
git -C "$ROOT_DIR" add frontend
|
||||
if ! git -C "$ROOT_DIR" diff --cached --quiet; then
|
||||
git -C "$ROOT_DIR" commit -m "$ROOT_MSG"
|
||||
else
|
||||
echo "根目錄無 submodule pointer 變更,略過根目錄 commit。"
|
||||
fi
|
||||
|
||||
git -C "$ROOT_DIR" push origin master
|
||||
|
||||
echo "完成: frontend 與根目錄已 push。"
|
||||
Reference in New Issue
Block a user