mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-15 04:18:20 +00:00
Compare commits
85 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
972bf3bd5d | ||
|
|
8f32716745 | ||
|
|
88481059c2 | ||
|
|
e63265d845 | ||
|
|
15332a9849 | ||
|
|
5b0812ed4b | ||
|
|
b095b242a4 | ||
|
|
accdfeba94 | ||
|
|
217a2225d4 | ||
|
|
1d7daeb150 | ||
|
|
27f044b0f5 | ||
|
|
37bca5a319 | ||
|
|
0b3bbeab3b | ||
|
|
3bfd8a322a | ||
|
|
57bb09fde7 | ||
|
|
71790bc1b8 | ||
|
|
7893331cc1 | ||
|
|
195d1bded4 | ||
|
|
d8dbd4f0c9 | ||
|
|
642e780f1e | ||
|
|
11ff769532 | ||
|
|
84116292d5 | ||
|
|
6f38e40880 | ||
|
|
0cfae1799d | ||
|
|
b67b9553e6 | ||
|
|
ccd171eac9 | ||
|
|
a56486812e | ||
|
|
5916771237 | ||
|
|
8a69827286 | ||
|
|
be1529aeda | ||
|
|
efbb50f4aa | ||
|
|
3de13c033f | ||
|
|
71d0598d92 | ||
|
|
d275d1e9b1 | ||
|
|
bfb5298c9a | ||
|
|
66571f709b | ||
|
|
4a2a1f78f6 | ||
|
|
b469b60997 | ||
|
|
52f694f0b6 | ||
|
|
c5f8483cbe | ||
|
|
d9c658a459 | ||
|
|
14cc3f11eb | ||
|
|
1a7ffbe6e2 | ||
|
|
23e36e4356 | ||
|
|
8c7ea2c41a | ||
|
|
3b3e5ed27c | ||
|
|
bc2592c978 | ||
|
|
c48b09d3a0 | ||
|
|
5f2d47968c | ||
|
|
17a18b9319 | ||
|
|
b67ea7ddb8 | ||
|
|
e65795d313 | ||
|
|
ee17c1eb43 | ||
|
|
44a1623620 | ||
|
|
6299733dd8 | ||
|
|
f12019cf14 | ||
|
|
ff69136ddb | ||
|
|
08fb846b15 | ||
|
|
7d36d42056 | ||
|
|
27687713d2 | ||
|
|
0452af0793 | ||
|
|
9c997f7eca | ||
|
|
f7e96bb8a4 | ||
|
|
b1d5391102 | ||
|
|
6d42bd6708 | ||
|
|
57477e2614 | ||
|
|
56531d2692 | ||
|
|
a5729d24e1 | ||
|
|
9bf83e0e87 | ||
|
|
a463efb535 | ||
|
|
68a99b21bd | ||
|
|
55a5c93183 | ||
|
|
24608f4d5d | ||
|
|
562b520d69 | ||
|
|
1bfbfdd720 | ||
|
|
44a311ea80 | ||
|
|
f630519625 | ||
|
|
b4a037b0ff | ||
|
|
6842b40132 | ||
|
|
e8de0059ef | ||
|
|
5120f4f046 | ||
|
|
a2249454cf | ||
|
|
ad2412c075 | ||
|
|
f297fdfd09 | ||
|
|
df9b869d8e |
361 changed files with 25219 additions and 942 deletions
24
.github/workflows/backend-tests.yml
vendored
Normal file
24
.github/workflows/backend-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: backend-tests
|
||||||
|
|
||||||
|
# Backend test suite for pull requests. Testcontainers-based tests need a
|
||||||
|
# Docker daemon, which the GitHub-hosted runners provide.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: ["feat/authoring-*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
cache: maven
|
||||||
|
|
||||||
|
- name: Backend tests
|
||||||
|
run: cd server && ./mvnw -B test
|
||||||
|
|
@ -51,6 +51,13 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \
|
||||||
`<publicBaseUrl>/cli/auth`。所有 values 会先经过 `values.schema.json` 和跨字段校验,
|
`<publicBaseUrl>/cli/auth`。所有 values 会先经过 `values.schema.json` 和跨字段校验,
|
||||||
无效的组件、Ingress、HPA 与存储组合会在安装前失败。
|
无效的组件、Ingress、HPA 与存储组合会在安装前失败。
|
||||||
|
|
||||||
|
首次安装可通过 `auth.initialSettings.passwordLoginEnabled` 和
|
||||||
|
`auth.initialSettings.selfRegistrationEnabled` 设置本地认证开关的初始值。
|
||||||
|
若要预置外部账号首次授权规则,在受保护的 values 中设置
|
||||||
|
`secrets.initialRoleGrantsJson`,或在 `existingSecret` 中提供
|
||||||
|
`auth-initial-role-grants-json`。这些值仅用于数据库首次初始化;之后在后台
|
||||||
|
“系统配置”中管理。规则格式及管理入口见 [登录开关与外部账号首次授权](../../docs/923-auth-system-settings.md)。
|
||||||
|
|
||||||
> **Ingress values 迁移:** 当前版本只支持结构化的 `ingress.hosts[]` 和
|
> **Ingress values 迁移:** 当前版本只支持结构化的 `ingress.hosts[]` 和
|
||||||
> `ingress.tls[]`。旧的 `ingress.host`、`ingress.tls.enabled` 与
|
> `ingress.tls[]`。旧的 `ingress.host`、`ingress.tls.enabled` 与
|
||||||
> `ingress.tls.secretName` 不再接受,升级前必须改成本文 Ingress 示例中的数组结构。
|
> `ingress.tls.secretName` 不再接受,升级前必须改成本文 Ingress 示例中的数组结构。
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ data:
|
||||||
{{- end }}
|
{{- end }}
|
||||||
device-auth-verification-uri: {{ $deviceAuthVerificationUri | quote }}
|
device-auth-verification-uri: {{ $deviceAuthVerificationUri | quote }}
|
||||||
auth-direct-enabled: {{ .Values.auth.direct.enabled | quote }}
|
auth-direct-enabled: {{ .Values.auth.direct.enabled | quote }}
|
||||||
|
auth-initial-password-login-enabled: {{ .Values.auth.initialSettings.passwordLoginEnabled | quote }}
|
||||||
|
auth-initial-self-registration-enabled: {{ .Values.auth.initialSettings.selfRegistrationEnabled | quote }}
|
||||||
auth-direct-provider: {{ .Values.auth.direct.provider | quote }}
|
auth-direct-provider: {{ .Values.auth.direct.provider | quote }}
|
||||||
|
|
||||||
# Sub-path deployment (empty keeps a fixed-base image's baked base; set e.g. /portal/)
|
# Sub-path deployment (empty keeps a fixed-base image's baked base; set e.g. /portal/)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ stringData:
|
||||||
{{- end }}
|
{{- end }}
|
||||||
bootstrap-admin-password: {{ $baPwd | quote }}
|
bootstrap-admin-password: {{ $baPwd | quote }}
|
||||||
|
|
||||||
|
{{- if .Values.secrets.initialRoleGrantsJson }}
|
||||||
|
auth-initial-role-grants-json: {{ .Values.secrets.initialRoleGrantsJson | quote }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
# 匿名下载限流 Cookie 签名密钥
|
# 匿名下载限流 Cookie 签名密钥
|
||||||
{{- $downloadSecret := .Values.secrets.downloadAnonCookieSecret | default "" }}
|
{{- $downloadSecret := .Values.secrets.downloadAnonCookieSecret | default "" }}
|
||||||
{{- if and (not $downloadSecret) $appSecret }}
|
{{- if and (not $downloadSecret) $appSecret }}
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,22 @@ spec:
|
||||||
configMapKeyRef:
|
configMapKeyRef:
|
||||||
name: {{ include "skillhub.fullname" . }}-config
|
name: {{ include "skillhub.fullname" . }}-config
|
||||||
key: auth-direct-enabled
|
key: auth-direct-enabled
|
||||||
|
- name: SKILLHUB_AUTH_INITIAL_PASSWORD_LOGIN_ENABLED
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "skillhub.fullname" . }}-config
|
||||||
|
key: auth-initial-password-login-enabled
|
||||||
|
- name: SKILLHUB_AUTH_INITIAL_SELF_REGISTRATION_ENABLED
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "skillhub.fullname" . }}-config
|
||||||
|
key: auth-initial-self-registration-enabled
|
||||||
|
- name: SKILLHUB_AUTH_INITIAL_ROLE_GRANTS_JSON
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "skillhub.secretName" . }}
|
||||||
|
key: auth-initial-role-grants-json
|
||||||
|
optional: true
|
||||||
- name: SKILLHUB_BUILTIN_SKILLS_ENABLED
|
- name: SKILLHUB_BUILTIN_SKILLS_ENABLED
|
||||||
valueFrom:
|
valueFrom:
|
||||||
configMapKeyRef:
|
configMapKeyRef:
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ spec:
|
||||||
imagePullPolicy: {{ .Values.images.pullPolicy }}
|
imagePullPolicy: {{ .Values.images.pullPolicy }}
|
||||||
env:
|
env:
|
||||||
- name: SKILLHUB_API_UPSTREAM
|
- name: SKILLHUB_API_UPSTREAM
|
||||||
value: http://{{ include "skillhub.fullname" . }}-server:{{ .Values.server.service.port }}
|
value: {{ printf "http://%s-server.%s.svc.%s:%v" (include "skillhub.fullname" .) .Release.Namespace (.Values.web.clusterDomain | default "cluster.local") .Values.server.service.port | quote }}
|
||||||
- name: SKILLHUB_PUBLIC_BASE_URL
|
- name: SKILLHUB_PUBLIC_BASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
configMapKeyRef:
|
configMapKeyRef:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,57 @@ assert_rejected() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert_web_upstream() {
|
||||||
|
local manifest=$1
|
||||||
|
local expected=$2
|
||||||
|
[[ $(grep -Fc 'name: SKILLHUB_API_UPSTREAM' "$manifest") -eq 1 ]] \
|
||||||
|
|| fail "Web must render exactly one built-in API upstream"
|
||||||
|
grep -A1 -F 'name: SKILLHUB_API_UPSTREAM' "$manifest" \
|
||||||
|
| grep -Fq "value: \"$expected\"" \
|
||||||
|
|| fail "Web runtime DNS upstream must be $expected"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nginx runtime DNS resolution does not expand the pod's search suffixes.
|
||||||
|
render web-dns "$CHART_DIR" --namespace default \
|
||||||
|
--show-only templates/web-deployment.yaml >"$TMP_DIR/web-dns.yaml"
|
||||||
|
assert_web_upstream "$TMP_DIR/web-dns.yaml" \
|
||||||
|
'http://web-dns-skillhub-server.default.svc.cluster.local:8080'
|
||||||
|
|
||||||
|
render skillhub "$CHART_DIR" --namespace team-skills \
|
||||||
|
--show-only templates/web-deployment.yaml >"$TMP_DIR/web-dns-namespace.yaml"
|
||||||
|
assert_web_upstream "$TMP_DIR/web-dns-namespace.yaml" \
|
||||||
|
'http://skillhub-server.team-skills.svc.cluster.local:8080'
|
||||||
|
|
||||||
|
render web-dns "$CHART_DIR" --namespace team-skills \
|
||||||
|
--set nameOverride=registry \
|
||||||
|
--show-only templates/web-deployment.yaml >"$TMP_DIR/web-dns-name.yaml"
|
||||||
|
assert_web_upstream "$TMP_DIR/web-dns-name.yaml" \
|
||||||
|
'http://web-dns-registry-server.team-skills.svc.cluster.local:8080'
|
||||||
|
|
||||||
|
render web-dns "$CHART_DIR" --namespace team-skills \
|
||||||
|
--set fullnameOverride=custom-hub --set server.service.port=9080 \
|
||||||
|
--set web.clusterDomain=k8s.example.internal \
|
||||||
|
--show-only templates/web-deployment.yaml >"$TMP_DIR/web-dns-custom.yaml"
|
||||||
|
assert_web_upstream "$TMP_DIR/web-dns-custom.yaml" \
|
||||||
|
'http://custom-hub-server.team-skills.svc.k8s.example.internal:9080'
|
||||||
|
|
||||||
|
# --reuse-values can supply old chart defaults without this newly added key.
|
||||||
|
# Model that effective values shape here; a real cross-version upgrade is a
|
||||||
|
# separate integration check, not a second render of this chart's new defaults.
|
||||||
|
LEGACY_VALUES_CHART="$TMP_DIR/legacy-values-chart"
|
||||||
|
cp -R "$CHART_DIR" "$LEGACY_VALUES_CHART"
|
||||||
|
sed '/^ clusterDomain:/d' "$CHART_DIR/values.yaml" >"$LEGACY_VALUES_CHART/values.yaml"
|
||||||
|
render web-dns "$LEGACY_VALUES_CHART" --namespace team-skills \
|
||||||
|
--set fullnameOverride=custom-hub --set server.service.port=9080 \
|
||||||
|
--show-only templates/web-deployment.yaml >"$TMP_DIR/web-dns-legacy-values.yaml"
|
||||||
|
assert_web_upstream "$TMP_DIR/web-dns-legacy-values.yaml" \
|
||||||
|
'http://custom-hub-server.team-skills.svc.cluster.local:9080'
|
||||||
|
|
||||||
|
assert_rejected web-dns-empty-domain --set-string web.clusterDomain=
|
||||||
|
assert_rejected web-dns-url-domain --set-string web.clusterDomain=https://cluster.local
|
||||||
|
assert_rejected web-dns-empty-label --set-string web.clusterDomain=cluster..local
|
||||||
|
assert_rejected web-dns-invalid-label --set-string web.clusterDomain=-cluster.local
|
||||||
|
|
||||||
render verify "$CHART_DIR" >"$TMP_DIR/default.yaml"
|
render verify "$CHART_DIR" >"$TMP_DIR/default.yaml"
|
||||||
grep -Fq 'name: POSTGRESQL_MAX_CONNECTIONS' "$TMP_DIR/default.yaml"
|
grep -Fq 'name: POSTGRESQL_MAX_CONNECTIONS' "$TMP_DIR/default.yaml"
|
||||||
grep -Fq 'value: "verify-postgresql"' "$TMP_DIR/default.yaml"
|
grep -Fq 'value: "verify-postgresql"' "$TMP_DIR/default.yaml"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
"auth": {
|
"auth": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["direct"],
|
"required": ["direct", "initialSettings"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"direct": {
|
"direct": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -31,6 +31,15 @@
|
||||||
"enabled": { "type": "boolean" },
|
"enabled": { "type": "boolean" },
|
||||||
"provider": { "type": "string" }
|
"provider": { "type": "string" }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"initialSettings": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["passwordLoginEnabled", "selfRegistrationEnabled"],
|
||||||
|
"properties": {
|
||||||
|
"passwordLoginEnabled": { "type": "boolean" },
|
||||||
|
"selfRegistrationEnabled": { "type": "boolean" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -183,6 +192,7 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"allowAutoGenerated": { "type": "boolean" },
|
"allowAutoGenerated": { "type": "boolean" },
|
||||||
"bootstrapAdminPassword": { "type": "string" },
|
"bootstrapAdminPassword": { "type": "string" },
|
||||||
|
"initialRoleGrantsJson": { "type": "string" },
|
||||||
"downloadAnonCookieSecret": { "type": "string" },
|
"downloadAnonCookieSecret": { "type": "string" },
|
||||||
"oauth2GithubClientId": { "type": "string" },
|
"oauth2GithubClientId": { "type": "string" },
|
||||||
"oauth2GithubClientSecret": { "type": "string" },
|
"oauth2GithubClientSecret": { "type": "string" },
|
||||||
|
|
@ -472,6 +482,12 @@
|
||||||
"required": ["replicaCount", "image", "service", "resources", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"],
|
"required": ["replicaCount", "image", "service", "resources", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"replicaCount": { "type": "integer", "minimum": 1 },
|
"replicaCount": { "type": "integer", "minimum": 1 },
|
||||||
|
"clusterDomain": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 253,
|
||||||
|
"pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$"
|
||||||
|
},
|
||||||
"image": { "$ref": "#/definitions/image" },
|
"image": { "$ref": "#/definitions/image" },
|
||||||
"service": { "$ref": "#/definitions/service" },
|
"service": { "$ref": "#/definitions/service" },
|
||||||
"basePath": { "type": "string", "pattern": "^$|^/$|^(/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+/$" },
|
"basePath": { "type": "string", "pattern": "^$|^/$|^(/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+/$" },
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ auth:
|
||||||
direct:
|
direct:
|
||||||
enabled: true
|
enabled: true
|
||||||
provider: local
|
provider: local
|
||||||
|
initialSettings:
|
||||||
|
passwordLoginEnabled: true
|
||||||
|
selfRegistrationEnabled: true
|
||||||
|
|
||||||
oauth2:
|
oauth2:
|
||||||
feishu:
|
feishu:
|
||||||
|
|
@ -103,6 +106,8 @@ secrets:
|
||||||
# 默认禁止随机 Secret;仅在非 GitOps 临时环境中按需启用
|
# 默认禁止随机 Secret;仅在非 GitOps 临时环境中按需启用
|
||||||
allowAutoGenerated: false
|
allowAutoGenerated: false
|
||||||
bootstrapAdminPassword: ""
|
bootstrapAdminPassword: ""
|
||||||
|
# JSON array of {provider,email,role}; used only while the database is empty.
|
||||||
|
initialRoleGrantsJson: ""
|
||||||
downloadAnonCookieSecret: ""
|
downloadAnonCookieSecret: ""
|
||||||
oauth2GithubClientId: ""
|
oauth2GithubClientId: ""
|
||||||
oauth2GithubClientSecret: ""
|
oauth2GithubClientSecret: ""
|
||||||
|
|
@ -386,6 +391,9 @@ server:
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
web:
|
web:
|
||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
|
# Nginx resolves the API upstream at runtime without pod DNS search suffixes.
|
||||||
|
# Set the cluster DNS domain here instead of duplicating SKILLHUB_API_UPSTREAM in extraEnv.
|
||||||
|
clusterDomain: cluster.local
|
||||||
image:
|
image:
|
||||||
registry: ""
|
registry: ""
|
||||||
tag: ""
|
tag: ""
|
||||||
|
|
|
||||||
59
docs/26-skill-avatars.md
Normal file
59
docs/26-skill-avatars.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# 技能头像与审核
|
||||||
|
|
||||||
|
技能所有者可在新增/更新技能页面选择自定义 PNG/JPEG 头像,网页提供方形裁剪和缩放;不提供预设图标。更新默认保留头像,也可替换或移除。“我的技能”统一从“更新”入口维护,也可只改头像而不发布新版本;详情页仅在标题旁展示当前头像。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。所有者在“我的技能”和更新页可查看已提交头像的缩略图及审核状态,待审头像不会替代公开头像;加载失败会提示重试。
|
||||||
|
|
||||||
|
## 上传与提交
|
||||||
|
|
||||||
|
选择图片后先裁剪并上传到私有存储,上传成功后才允许提交技能或头像修改;未选择头像也可提交新技能。上传失败可重试,但不能把失败或尚未完成的上传随技能提交。上传成功仅表示文件已保存,不表示审核通过。
|
||||||
|
|
||||||
|
提交成功后图片才关联到技能并进入对应审核流程。更新时未修改头像会保留原头像;选择移除时先预览首字母。仅改头像时提交成功即恢复兜底;与新技能包一起提交时在同一申请审核通过后移除,驳回保留原头像。失效候选历史保留。取消编辑或提交失败不会改变原头像及已有待审申请。未关联的临时图片保持私有;上传引用 24 小时后过期,过期未关联图片由清理任务定期回收,提交时过期需要重新上传。
|
||||||
|
|
||||||
|
技能包与头像同次提交时只有一张技能审核申请:审核员在原技能审核详情同时检查内容与头像,一次批准或驳回,共同生效。机器结果作为同一申请的审批条件,不能提前发布头像。单改头像不创建新技能版本;需要人工决定时仍进入原技能审核队列,标明“头像修改”,不再有独立头像审核队列。
|
||||||
|
|
||||||
|
## 审核策略
|
||||||
|
|
||||||
|
超级管理员在“系统配置”中分别开关机器审核和人工审核。默认关闭机器审核、开启人工审核。单改头像时,两个开关都关闭直接发布;只开启机器时通过即发布、阻止或未知结果保持私有;只开启人工时进入技能审核队列;两者都开启时先机器后人工。若与技能包同次提交,则不论头像开关如何,仍等待原有技能内容审核共同批准。机器阻止后所有者可申请人工复核;机器服务失败时可重试,不会自动放行。当前云供应商不能提供可证明不可变的模型版本,不跨申请复用机器结果;新申请重新检测。每次调用记录实际服务/API版本和内部审核策略revision,未知模型版本留空,不把SDK或API版本作为模型版本。扩大可见范围时必须重新人工审核。`PRIVATE` 技能头像由所有者直接使用。
|
||||||
|
|
||||||
|
人工权限复用技能审核:团队管理员可审本团队,平台技能管理员/超级管理员可审团队和全局;自审限制也沿用原技能审核规则。头像只能由技能所有者提交、替换或删除;管理员可审核和撤下,不可代传。新申请使旧待审申请失效;旧审核决定不能覆盖新头像。被拒绝、替换、删除的候选图片私有保留 30 天后清理,文字审核记录继续保留。头像撤下后显示首字母兜底。
|
||||||
|
|
||||||
|
新提交若可见范围扩大,系统在同一技能申请中关联目标范围头像候选,审核完成后共同生效,不能先发布内容再要求另一次人工审核。遗留数据的范围补偿仍保证未经目标范围批准的头像不向新增受众公开。已有相同目标范围的待审申请不会被覆盖;新旧候选使用不同对象键,避免旧文件到期清理影响当前头像。自动建候选失败时旧头像仍保持隐藏;每五分钟分批检查并补建候选。若候选已建但机器读取或审核失败,状态记为 `ERROR`,一小时冷却后自动重试;同一候选自动审核最多调用三次(含首次),到达上限后由所有者在冷却结束后主动重试或重新提交。普通上传在机器调用前宕机留下的 `PENDING` 候选,以及机器处理中宕机留下的 `PROCESSING` 候选,十分钟后也由补偿任务重领。机器调用前后分别使用短事务核验候选指针,外部调用期间不持有数据库锁;过期尝试的迟到结果不会激活头像。当前发布版已驳回的自动申请不会反复入队,历史发布版的驳回不阻止新发布。服务日志仅记录技能或候选 ID 和异常类型,不记录可能包含凭证的供应商请求信息。`PRIVATE` 头像及候选图片只有技能所有者可以读取。
|
||||||
|
|
||||||
|
## 部署配置
|
||||||
|
|
||||||
|
### 机器结果与人工决定
|
||||||
|
|
||||||
|
机器审核结果与申请的最终决定是两个状态。以下针对非 `PRIVATE` 的单改头像;与技能包同时提交时,仍需等待原技能审核共同决定。
|
||||||
|
|
||||||
|
| 机器结果 | 开启人工审核 | 未开启人工审核 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 通过(`PASSED`) | 进入统一技能审核,批准后生效 | 自动生效 |
|
||||||
|
| 阻止(`BLOCKED`) | 新头像保持私有,申请待审;审核员可拒绝,或填写明确理由后人工改判 | 不生效,不自动变为人工驳回;所有者可申请人工复核 |
|
||||||
|
| 异常(`ERROR`) | 保持私有,不允许直接批准;先重试得到有效结果 | 保持私有,等待重试 |
|
||||||
|
|
||||||
|
文件上传成功不等于机器通过;机器阻止也不等于管理员已经拒绝。人工拒绝后,用户可查看提交图片及拒绝原因,公开头像保持旧图或首字母兜底。机器阻止没有自动驳回整个技能申请,避免把机器判断与人工决定混为一谈。
|
||||||
|
|
||||||
|
### 供应商与凭证
|
||||||
|
|
||||||
|
在部署密钥配置中指定唯一供应商及其凭证,然后才可从后台开启机器审核。Spring 属性如下;将值映射到部署 Secret/环境变量时请遵循当前部署平台的配置方式,不要写入仓库或后台数据库:
|
||||||
|
|
||||||
|
| 供应商 | 属性 |
|
||||||
|
| --- | --- |
|
||||||
|
| 通用 | `skillhub.avatar.moderation.provider`:`xfyun` 或 `aliyun` |
|
||||||
|
| 讯飞 | `skillhub.avatar.moderation.xfyun.app-id`、`skillhub.avatar.moderation.xfyun.access-key-id`、`skillhub.avatar.moderation.xfyun.access-key-secret` |
|
||||||
|
| 阿里云 | `skillhub.avatar.moderation.aliyun.access-key-id`、`skillhub.avatar.moderation.aliyun.access-key-secret` |
|
||||||
|
|
||||||
|
后台只显示两个启停开关,不回显供应商或密钥。阿里云适配器使用上海地域的 `profilePhotoCheck`,通过短期上传令牌把私有候选送到官方 OSS 临时对象,再调用同步审核;不会公开候选 URL。官方返回 `RiskLevel=none/low` 通过,`medium/high` 保持私有并允许人工复核。把 `medium` 归为阻止是本产品策略。响应非 200、空结果或未知值均不放行。机器接口可能按量计费,开启前由部署方确认供应商账号与费用。
|
||||||
|
|
||||||
|
阿里云须开通内容安全增强版,测试凭证至少需要 `yundun-greenweb:DescribeUploadToken` 和 `yundun-greenweb:ImageModeration` 权限。当前使用供应商临时上传令牌,无需部署方自建审核用 OSS Bucket 或向该凭证授予自有 OSS 全权限。[官方本地图片接入示例](https://help.aliyun.com/zh/document_detail/467828.html)即使用该流程。阿里云说明上传到其临时存储的本地图片保留 30 分钟;这与 SkillHub 私有候选图片保留 30 天是两个独立生命周期,见[官方接口说明](https://help.aliyun.com/document_detail/467829.html)。
|
||||||
|
|
||||||
|
切换部署供应商需要更新服务配置并重启应用。已有候选保留提交时的供应商与机器/人工开关快照,不自动迁移;尚需调用旧供应商的申请在切换后会得到 `ERROR`,不会偷偷改用新供应商。所有者应重新提交以使用新供应商;已经完成的机器结果不会因切换配置被重新检测。
|
||||||
|
|
||||||
|
讯飞官方图片审核接口要求把 `accessKeySecret` 放在请求 URL 参数中。应用不会记录完整 URL 或异常原文,但 Java HTTP 客户端调试日志、反向代理、出口代理、链路追踪及第三方日志仍可能记录该 URL。部署方使用讯飞适配器前,必须确认相关组件不采集请求 URL,限制凭证权限并建立轮换机制。若无法满足,先不要开启该适配器。
|
||||||
|
|
||||||
|
## 与原 Issue 的差异
|
||||||
|
|
||||||
|
原 Issue 提议自动图片审核;本期产品决策改为“上传、裁剪、机器审核可选、人工审核可选”的四种策略。默认使用人工审核,机器审核仅在部署好单一供应商且管理员开启后运行。个人头像不在本期范围。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
分别验证四种开关组合、候选与当前头像隔离、拥有者和审核员权限、审核通过/驳回/复核、相同图片的新申请重新检测及每次策略审计、未知机器结果不放行、连续替换后旧审核无效、删除后首字母兜底、30 天清理以及团队/全局可见范围变化。外部供应商真实计费调用需要部署方测试账号与授权;单元测试使用模拟响应。
|
||||||
29
docs/27-authoring-drafts.md
Normal file
29
docs/27-authoring-drafts.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# 草稿与结构校验 API
|
||||||
|
|
||||||
|
这是创作平台的第一层:创建私有草稿、维护文件和运行结构校验。尚不包含网页工作台、模型执行、MCP 探测、自动修复确认或发布集成。草稿校验通过不等于技能已发布,也不代表运行效果或安全扫描通过。
|
||||||
|
|
||||||
|
## 权限与文件
|
||||||
|
|
||||||
|
接口同时支持 `/api/v1/authoring` 与 `/api/web/authoring`;复用平台认证及浏览器 CSRF 规则。草稿与运行的读取、编辑和事件订阅仅允许所有者或超级管理员。创建草稿还要求目标空间可写,并满足已有空间成员权限。
|
||||||
|
|
||||||
|
`POST /drafts` 创建草稿并生成 `SKILL.md` 模板。文件操作在 `/drafts/{draftId}/files`,读取内容在 `/drafts/{draftId}/files/content?path=...`。保存请求可带 `expectedRevision`;版本不匹配返回冲突。同一草稿的写入串行化,文件仍受技能包路径、扩展名、单文件大小、文件数量和总大小限制。
|
||||||
|
|
||||||
|
文件保存和读取的 `encoding` 为 `text` 或 `base64`。合法 UTF-8 内容返回 `text`,非 UTF-8 字节返回 `base64`;调用方按返回的 encoding 解码,保存时使用相同 encoding,不能将图片等二进制内容当普通字符串处理。
|
||||||
|
|
||||||
|
删除草稿先提交数据库事务,再清理私有对象;数据库回滚不会删除仍被引用的文件。提交后存储清理失败会留下不可达的私有对象,并记录清理失败,不假装数据库删除失败。
|
||||||
|
|
||||||
|
## 运行与事件
|
||||||
|
|
||||||
|
`POST /drafts/{draftId}/runs` 为当前草稿 revision 创建运行。同一草稿最多有一个活动运行。执行时核验 revision 并捕获稳定的文件内容;排队期间草稿被修改则该运行失败,不能把新版内容冒充旧版的检测结果。已捕获内容不随之后的编辑改变;只有成功且草稿 revision 仍匹配的运行才更新草稿校验标记。
|
||||||
|
|
||||||
|
运行状态、事件和 findings 在 `/runs/{runId}` 及其子接口读取。SSE 在 `/runs/{runId}/events/stream`,可用 `afterSeq` 或 `Last-Event-ID` 恢复,按连续序号发送回放和实时事件。只有真正终态才能记录 `RUN_FINISHED`,重复完成不会产生第二条终态事件。连接或缓冲超过限制时关闭/拒绝连接,客户端可凭最后确认的事件编号重连,或通过轮询获取持久化记录。
|
||||||
|
|
||||||
|
执行器为有界线程池;队列满时已创建的排队运行会受控失败,不由 HTTP 请求线程代为执行,也不留下永久排队记录。取消或超时先使运行落入终态,阻止后续事件/finding 写入,再协作中断本机任务。中断不保证硬停止不响应中断的计算或外部 I/O,不能视为进程级沙箱。
|
||||||
|
|
||||||
|
## 配置与迁移
|
||||||
|
|
||||||
|
部署属性为 `skillhub.authoring.executor-threads`、`run-timeout-ms`、`stale-run-minutes` 与 `workspace-root`。默认执行线程 4、执行期限 900,000 毫秒、遗留活动运行清理阈值 30 分钟;期限从任务实际执行开始计算,遗留清理是恢复机制。修改启动配置需要重启应用。维护任务不会因数据库暂时失败就删除工作目录。
|
||||||
|
|
||||||
|
数据库迁移为未发布的 `V69__authoring_platform.sql`。正 revision、非负大小/计数及状态转换在业务代码中验证;数据库保留主键、外键和唯一性结构约束。头像/邀请 PR #947 使用 V70–V74,须在 V69 合入后验证其升级顺序,避免把较低编号的迁移留到已应用较高版本之后。
|
||||||
|
|
||||||
|
API 的完整字段、操作与响应以服务生成的 OpenAPI 为准;本文件不替代接口定义。
|
||||||
31
docs/923-auth-system-settings.md
Normal file
31
docs/923-auth-system-settings.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# 登录开关与外部账号首次授权
|
||||||
|
|
||||||
|
超级管理员在“系统配置”中分别控制本地密码登录和本地账号自行注册,默认都开启。关闭密码登录后,本地注册也暂时不可用,因为当前注册流程会建立本地会话;注册开关的原值会保留。关闭密码登录还会阻止密码重置、密码修改和直接密码认证。已有会话按原有效期结束,不会立刻踢下线。
|
||||||
|
|
||||||
|
关闭密码登录前,页面会提示可能失去管理入口,但允许继续。部署方应先确认至少有一位超级管理员能通过外部身份登录。若锁定管理入口,使用受控数据库操作恢复 `system_setting` 中 `auth.local` 的 `passwordLoginEnabled`,并记录操作;仅修改启动参数不会覆盖已有数据库设置。
|
||||||
|
|
||||||
|
## 空库初始化
|
||||||
|
|
||||||
|
全新安装且尚无用户时,启动参数提供初始值。已有用户的实例升级时,若尚无 `auth.local` 记录,首次启动会把两个开关都设为开启,以保持原有登录行为:
|
||||||
|
|
||||||
|
| 环境变量 | Helm 值 | 默认值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SKILLHUB_AUTH_INITIAL_PASSWORD_LOGIN_ENABLED` | `auth.initialSettings.passwordLoginEnabled` | `true` |
|
||||||
|
| `SKILLHUB_AUTH_INITIAL_SELF_REGISTRATION_ENABLED` | `auth.initialSettings.selfRegistrationEnabled` | `true` |
|
||||||
|
| `SKILLHUB_AUTH_INITIAL_ROLE_GRANTS_JSON` | `secrets.initialRoleGrantsJson` | `[]` |
|
||||||
|
|
||||||
|
首次授权规则的 JSON 示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{"provider":"github","email":"admin@example.com","role":"SUPER_ADMIN"}]
|
||||||
|
```
|
||||||
|
|
||||||
|
规则仅在第一次初始化且 `user_account` 为空时写入;初始化标记防止以后删除规则再重启时重复创建。正式环境请通过 Secret 提供 JSON,不要将实际邮箱写进公开的 values 文件。初始化以后,数据库和“系统配置”页面是权威来源。
|
||||||
|
|
||||||
|
## 授权边界
|
||||||
|
|
||||||
|
规则按身份来源代码和**已验证邮箱**匹配。只有外部身份通过现有准入策略、并且新账号首次创建为可用状态时,才给新账号授予选定平台角色;规则随后标为“已授权”,不会再次使用。拒绝或待审批的登录不会触发授权,规则不会绕过准入策略。已有账号请在“用户管理”中直接授权。相同邮箱的本地账号不会因此合并或获得角色。
|
||||||
|
|
||||||
|
当前飞书和钉钉适配器把邮箱标记为未验证:它们的用户资料没有提供可证明用户控制该邮箱的信号。因此后台和初始化均拒绝为 `feishu` 或 `dingtalk` 配置邮箱规则。GitHub 的已验证主邮箱、提供 `email_verified=true` 的 OIDC 身份等可匹配。飞书或钉钉账号首次进入后,可由已有超级管理员在用户管理中授权;如果部署方只允许飞书或钉钉登录,必须先安排其他可用的管理员入口。不能为了触发规则而把未验证邮箱改成已验证。
|
||||||
|
|
||||||
|
后台可以新增规则、调整待匹配规则的角色、停用规则,并查看已使用规则的匹配身份和授权账号。只允许超级管理员操作;修改带版本号,避免两个管理员同时改动时后写覆盖前写。紧急恢复可通过受控数据库操作处理,并保留审计记录。
|
||||||
|
|
@ -39,6 +39,7 @@ export default defineConfig({
|
||||||
text: '核心功能',
|
text: '核心功能',
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Skill 发布与版本管理', link: '/guide/skill-publish' },
|
{ text: 'Skill 发布与版本管理', link: '/guide/skill-publish' },
|
||||||
|
{ text: '技能头像与图片审核接入', link: '/guide/skill-avatars' },
|
||||||
{ text: 'Suite Bundle 批量导入', link: '/guide/suite-bundle' },
|
{ text: 'Suite Bundle 批量导入', link: '/guide/suite-bundle' },
|
||||||
{ text: 'Skill 搜索与发现', link: '/guide/skill-discovery' },
|
{ text: 'Skill 搜索与发现', link: '/guide/skill-discovery' },
|
||||||
{ text: '命名空间与团队管理', link: '/guide/namespace' },
|
{ text: '命名空间与团队管理', link: '/guide/namespace' },
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,15 @@ Discover a security issue in the namespace and temporarily freeze all publish op
|
||||||
5. Select a role (Owner / Admin / Member)
|
5. Select a role (Owner / Admin / Member)
|
||||||
6. Confirm to add
|
6. Confirm to add
|
||||||
|
|
||||||
|
**Invite with a link**:
|
||||||
|
|
||||||
|
1. An Owner or Admin opens the Members page of an ACTIVE team space and selects "Invite member".
|
||||||
|
2. Share the copied link securely. It expires after 24 hours and can be redeemed once by one person who is not already a member.
|
||||||
|
3. The recipient must first pass the usual SkillHub login and account access rules. The page shows the space, current account, and MEMBER permissions. Joining happens only after explicit confirmation.
|
||||||
|
4. "Revoke invitation" invalidates the link immediately. Creating a new link asks for confirmation before replacing a still usable one; existing members remain members.
|
||||||
|
|
||||||
|
Anyone who obtains the link can use it after normal account admission. Do not post it in a public channel. The login return URL does not contain the invitation token; the current browser tab stores it temporarily until joining or terminal invalidation.
|
||||||
|
|
||||||
**Manage Permissions**:
|
**Manage Permissions**:
|
||||||
|
|
||||||
1. Find the target user in the member list
|
1. Find the target user in the member list
|
||||||
|
|
@ -167,6 +176,16 @@ Content-Type: application/json
|
||||||
POST /api/v1/namespaces/{slug}/unfreeze
|
POST /api/v1/namespaces/{slug}/unfreeze
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Invite Members and View History
|
||||||
|
|
||||||
|
Team Owners and Admins can create a link from **Members → Invite members**. It expires after 24 hours and permits one new member to join. The recipient must sign in, meet existing admission rules, and confirm joining. Replacing an active link or revoking it requires confirmation and does not remove existing members.
|
||||||
|
|
||||||
|
The dialog includes a read-only history (five records per page, up to 240px tall) showing the creator, creation and expiration times, status, and the account that successfully joined. Snapshot names are displayed first; full account IDs are available on hover, with abbreviated IDs used when names are missing. Each generated invitation has its own record. Replacement, revocation, expiration, or a member leaving does not erase history. History cannot restore expired links and does not indicate current membership. Only the space's Owner/Admin can view it; platform roles do not bypass this restriction. Frozen or archived spaces retain a history-only entry.
|
||||||
|
|
||||||
|
An upgrade imports only the provable current invitation, marked as a legacy record; earlier links are not reconstructed from incomplete audit data. History remains while the space exists and is deleted when the space is permanently deleted. Existing audit records follow their original retention rules.
|
||||||
|
|
||||||
|
The history endpoint requires a browser session: `GET /api/v1/namespaces/{slug}/invitations/history?page=0&size=10`. Page sizes are bounded to 1–50; tokens and hashes are never returned.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
> **Slug Uniqueness**: Namespace slugs must be globally unique and cannot be changed after creation. Use a short identifier for your team or project.
|
> **Slug Uniqueness**: Namespace slugs must be globally unique and cannot be changed after creation. Use a short identifier for your team or project.
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,19 @@ If a developer discovers an issue, they can withdraw the submission before it is
|
||||||
3. Click "Withdraw"
|
3. Click "Withdraw"
|
||||||
4. Confirm the withdrawal
|
4. Confirm the withdrawal
|
||||||
|
|
||||||
|
### Deleting a Promoted Skill
|
||||||
|
|
||||||
|
If permanent deletion reports "Use the reviewed revocation workflow to remove a promoted skill", manage the global publication from the source team skill's detail page. Hiding, archiving, or withdrawing a pending submission does not remove an approved global skill. Submitting a revocation request does not complete revocation either.
|
||||||
|
|
||||||
|
1. Open the source skill, such as `@it/translate-document`. In "Promote to Global", choose "Request global revocation", enter a reason, and confirm.
|
||||||
|
2. Ask a platform `SKILL_ADMIN` or `SUPER_ADMIN` to open "Revocations to review" at `/dashboard/promotions`, verify the source and global skill, and choose "Approve revocation". A `SKILL_ADMIN` cannot review their own request; a `SUPER_ADMIN` can.
|
||||||
|
3. Refresh the source skill and check that the current request shows "Revoked" (`APPROVED`) in "Revocation history". `PENDING` or `REJECTED` means that this request has not completed revocation; ordinary deletion remains blocked while an active global link exists. A historical request's status is not a substitute for checking the current promotion relationship.
|
||||||
|
4. Approval deletes the linked `@global/translate-document` and all its global versions while keeping the source team skill. To remove the source too, use its permanent-delete action afterward with the required deletion permissions.
|
||||||
|
|
||||||
|
A platform `SKILL_ADMIN` or `SUPER_ADMIN` can also choose "Revoke directly as admin" on the source detail page when no revocation request is pending. Confirmation completes global revocation immediately and retains the revocation record. When a request is already pending, the detail page only shows its pending status; handle it through "Revocations to review" as described above. Check the confirmation carefully: this removes the entire global skill, including independently uploaded global versions, ratings, and statistics. Installed client files cannot be recalled, and another skill may later use the same address.
|
||||||
|
|
||||||
|
If the revocation record is already `APPROVED` but the same error persists after refreshing, record the source/global skill IDs, revocation request ID, deployed version or image tag, and the deletion response and request ID. Also check whether a new global promotion was approved after revocation. If the revocation controls are missing, first verify that the deployment and database migrations match the intended version and ask a maintainer to investigate. Do not edit promotion relationships directly in the database to bypass the workflow.
|
||||||
|
|
||||||
**Handling Reports**:
|
**Handling Reports**:
|
||||||
|
|
||||||
1. Navigate to `/dashboard/reports`
|
1. Navigate to `/dashboard/reports`
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,15 @@
|
||||||
5. 选择角色(Owner / Admin / Member)
|
5. 选择角色(Owner / Admin / Member)
|
||||||
6. 确认添加
|
6. 确认添加
|
||||||
|
|
||||||
|
**通过链接邀请成员**:
|
||||||
|
|
||||||
|
1. Owner 或 Admin 在处于 ACTIVE 状态的团队空间「成员」页点击「邀请成员」。
|
||||||
|
2. 复制链接并安全地发给要加入的人。链接 24 小时内有效,只能由一名尚未加入的用户使用一次。
|
||||||
|
3. 对方先按现有登录及账号准入规则进入 SkillHub。打开链接后会看到目标空间、当前账号和 MEMBER 权限;点击「确认加入」才会成为成员。
|
||||||
|
4. 如需提前失效,点击「作废邀请」。重新生成链接时,页面会要求确认旧链接立即失效;已加入的成员不受影响。
|
||||||
|
|
||||||
|
邀请链接是持有者可用的加入凭证,不限定邮箱或账号。不要在公开频道分享。登录回跳不会携带邀请 Token;同一浏览器标签页会暂存链接直到确认、已加入或确认失效。
|
||||||
|
|
||||||
**管理权限**:
|
**管理权限**:
|
||||||
|
|
||||||
1. 在成员列表中找到目标用户
|
1. 在成员列表中找到目标用户
|
||||||
|
|
@ -167,6 +176,16 @@ Content-Type: application/json
|
||||||
POST /api/v1/namespaces/{slug}/unfreeze
|
POST /api/v1/namespaces/{slug}/unfreeze
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 邀请成员与邀请记录
|
||||||
|
|
||||||
|
团队空间的 Owner/Admin 可在“成员 → 邀请成员”生成24小时有效、仅一名新成员可使用的链接。对方须先登录并通过平台准入,再确认加入;仅打开链接不会自动成为成员。替换有效链接和作废须确认,已加入成员不受影响。
|
||||||
|
|
||||||
|
弹窗下方直接展示分页邀请记录(网页每页5条,列表最高240px):创建人及时间、到期时间、状态,以及实际加入账号与时间。人员优先显示记录时保存的姓名,完整账号ID放在悬停提示;无姓名时显示短标识。每次生成独立记录,替换、作废、过期和成员离开不会抹掉原历史。历史不能恢复失效链接,也不代表当前成员资格。普通成员和仅有平台角色的账号不能查看,冻结/归档空间的 Owner/Admin 可查看只读历史。
|
||||||
|
|
||||||
|
升级只迁入可以准确读取的当前邀请并标记“旧记录”,不推测更早链接的使用情况。空间存在期间保留历史,彻底删除空间时删除邀请业务记录,既有操作审计按原规则保留。
|
||||||
|
|
||||||
|
历史查询使用浏览器 Session:`GET /api/v1/namespaces/{slug}/invitations/history?page=0&size=10`,每页1–50条,不返回邀请码或哈希。
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
> **Slug 唯一性**:命名空间 slug 在全局范围内必须唯一,且创建后不可修改。建议使用团队或项目的简短标识符。
|
> **Slug 唯一性**:命名空间 slug 在全局范围内必须唯一,且创建后不可修改。建议使用团队或项目的简短标识符。
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ SkillHub 提供了完整的审核工作流,确保发布到注册中心的技
|
||||||
- **推广管理**:将命名空间技能包推<EFBFBD><EFBFBD><EFBFBD>到全局
|
- **推广管理**:将命名空间技能包推<EFBFBD><EFBFBD><EFBFBD>到全局
|
||||||
- **审计日志**:记录所有治理操作
|
- **审计日志**:记录所有治理操作
|
||||||
|
|
||||||
|
头像变更复用技能审核申请。机器阻止与人工拒绝是不同状态;开关组合、审核操作及云审核接入步骤详见 [技能头像与图片审核接入](/guide/skill-avatars)。
|
||||||
|
|
||||||
## 使用场景
|
## 使用场景
|
||||||
|
|
||||||
**场景一:命名空间管理员审核**
|
**场景一:命名空间管理员审核**
|
||||||
|
|
@ -108,6 +110,19 @@ SkillHub 能保证的是结构正确、证据可追溯、版本快照不可变
|
||||||
3. 点击「撤回」
|
3. 点击「撤回」
|
||||||
4. 确认撤回
|
4. 确认撤回
|
||||||
|
|
||||||
|
### 删除已提升到全局的技能
|
||||||
|
|
||||||
|
如果永久删除时提示「已提升的技能须通过审核撤销流程删除」,请从团队来源技能的详情页处理全局发布。隐藏、归档或撤回待审提交不会删除已经批准的全局技能;提交撤销申请也不代表撤销已完成。
|
||||||
|
|
||||||
|
1. 打开来源技能(例如 `@it/translate-document`),在「提升到全局」区域点击「申请撤销全局发布」,填写原因并确认。
|
||||||
|
2. 请平台 `SKILL_ADMIN` 或 `SUPER_ADMIN` 访问 `/dashboard/promotions` 的「撤销待审核」页签,核对来源技能和全局技能后点击「批准撤销」。`SKILL_ADMIN` 不能审核自己提交的申请;`SUPER_ADMIN` 可以。
|
||||||
|
3. 刷新来源技能详情,核对本次申请在「撤销记录」中显示「已撤销」(`APPROVED`)。「待审核」(`PENDING`)或「已拒绝」(`REJECTED`)表示该申请未完成撤销;活动全局关联仍存在时,普通删除仍会被阻止。历史申请的状态不能代替当前提升关系的检查。
|
||||||
|
4. 撤销批准后,关联的 `@global/translate-document` 及其所有全局版本会被删除,团队来源技能保留。需要删除来源技能时,再使用该技能的「永久删除」,仍需具备删除权限。
|
||||||
|
|
||||||
|
尚无待审撤销申请时,平台 `SKILL_ADMIN` 或 `SUPER_ADMIN` 也可以在来源技能详情页使用「管理员直接撤销」,确认后立即完成全局撤销并保留撤销记录。已有待审申请时,详情页只显示待审提示,请通过上述「撤销待审核」入口处理。请先核对确认框:该操作会删除整条全局技能,包括独立上传的全局版本、评价和统计;已安装到客户端的文件无法收回,原地址以后可能被其他技能使用。
|
||||||
|
|
||||||
|
如果撤销记录已经是 `APPROVED`,刷新后仍提示同一错误,请记录来源/全局技能 ID、撤销申请 ID、当前部署版本或镜像标签,以及删除请求的响应和请求 ID。还需确认撤销后是否又进行了新的全局提升。若当前界面没有撤销入口,先核对部署版本和数据库迁移是否与对应版本一致,再由维护者检查;不要直接修改数据库中的提升关系来绕过流程。
|
||||||
|
|
||||||
**处理举报**:
|
**处理举报**:
|
||||||
|
|
||||||
1. 访问 `/dashboard/reports`
|
1. 访问 `/dashboard/reports`
|
||||||
|
|
|
||||||
151
docs/skillhub/guide/skill-avatars.md
Normal file
151
docs/skillhub/guide/skill-avatars.md
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
# 技能头像与图片审核接入
|
||||||
|
|
||||||
|
本页介绍如何上传技能头像,以及部署方如何接入讯飞或阿里云图片审核。这是**技能头像**功能,不涉及用户个人头像;图片审核与技能包安全扫描是两种不同的检查。
|
||||||
|
|
||||||
|
## 技能所有者怎么使用
|
||||||
|
|
||||||
|
1. 新增技能时,在发布页选择自定义 PNG/JPEG;已有技能从「我的技能 → 更新」进入,也可只改头像而不发布新版本。
|
||||||
|
2. 在裁剪弹窗调整方形区域和缩放,确认后等待上传成功。图片上传中或失败时不能提交;未选择头像也可正常提交,显示技能名首字母。
|
||||||
|
3. 提交后,在「我的技能」或更新页查看**本次提交的图片**及审核状态。审核通过前,其他人继续看到旧头像或首字母。
|
||||||
|
4. 被拒绝后查看原因,修改图片再提交。新申请会替换旧待审申请,旧审核结果不会覆盖新头像。
|
||||||
|
|
||||||
|
上传输入限 PNG/JPEG、2 MB、边长不超过 1024 像素;服务器会再次校验并重新编码为 PNG。上传成功仅表示图片已存入私有存储,不代表审核通过。临时上传引用 24 小时后过期,过期需要重新上传。
|
||||||
|
|
||||||
|
更新默认保留原头像,也可移除:单改头像提交成功后恢复首字母;与新技能包一起提交时,审核通过后才移除。取消编辑或提交失败不改变现有头像。技能详情和列表展示当前生效头像,不设单独的头像管理入口。
|
||||||
|
|
||||||
|
## 管理员怎么审核
|
||||||
|
|
||||||
|
头像与技能包同时提交时,共用一张技能申请,审核员在原审核详情中对内容和头像一次批准或拒绝。只改头像且需要人工决定时,也进入这个技能审核队列,标明「头像修改」,不创建新技能版本。
|
||||||
|
|
||||||
|
团队管理员审核本团队;平台技能管理员和超级管理员可审核团队及全局,自审权限沿用原技能审核规则。管理员可撤下头像,但不能替所有者上传新头像。
|
||||||
|
|
||||||
|
### 两个开关与四种流程
|
||||||
|
|
||||||
|
超级管理员在「系统配置」(`/dashboard/admin/system-config`)中选择「启用机器审核」与「启用人工审核」。默认机器关闭、人工开启。后台只管理这两个开关,不配置供应商、不显示密钥。
|
||||||
|
|
||||||
|
| 机器审核 | 人工审核 | 非 PRIVATE 技能单改头像的流程 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 关闭 | 关闭 | 提交成功后直接生效 |
|
||||||
|
| 关闭 | 开启 | 人工批准后生效 |
|
||||||
|
| 开启 | 关闭 | 机器通过后自动生效;阻止或异常时不生效 |
|
||||||
|
| 开启 | 开启 | 先机器审核,再由人工决定 |
|
||||||
|
|
||||||
|
**边界:**`PRIVATE` 技能头像仅所有者可读,直接使用,不运行上述机器或人工审核;扩大可见范围时需要按目标范围重新审核。头像与技能包同次提交时,不论头像开关如何,仍须等待原技能审核共同批准,不会提前发布头像。
|
||||||
|
|
||||||
|
### 违规会直接失败吗?
|
||||||
|
|
||||||
|
机器结果与申请最终决定不同:
|
||||||
|
|
||||||
|
| 结果 | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| `PASSED`(通过) | 有人工审核时继续待审;没有人工审核时单改头像自动生效 |
|
||||||
|
| `BLOCKED`(阻止) | 新图片保持私有、旧头像继续使用。开启人工审核时继续待审,审核员可拒绝;认为误判时必须填写明确理由才能批准。未开启人工审核时不生效,所有者可申请人工复核 |
|
||||||
|
| `ERROR`(异常) | 新图片保持私有,不能直接批准放行;先排查配置、网络或供应商服务,再重试 |
|
||||||
|
|
||||||
|
因此,**机器判违规不等于文件上传失败,也不等于人工已拒绝申请**。人工拒绝后,所有者可查看提交图片与拒绝原因。异常自动重试有一小时冷却,同一候选最多自动调用三次(含首次);耗尽后可在冷却结束后主动重试或重新提交。
|
||||||
|
|
||||||
|
## 部署方如何接入机器审核
|
||||||
|
|
||||||
|
### 1. 选择一个供应商
|
||||||
|
|
||||||
|
每个服务部署只选择一个:`xfyun` 或 `aliyun`。供应商及凭证在应用启动时读取;后台仅能启停审核,不能切换供应商。不开启机器审核时无需提供云审核凭证。
|
||||||
|
|
||||||
|
| 供应商 | 需要开通的服务 | 所需凭证 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 讯飞 | 图片合规审核 | APPID、APIKey、APISecret |
|
||||||
|
| 阿里云 | 内容安全增强版,上海地域的头像图片检测 `profilePhotoCheck` | RAM 用户的 AccessKey ID、AccessKey Secret |
|
||||||
|
|
||||||
|
服务开通、额度及计费以供应商控制台为准;开启后真实调用可能产生费用。
|
||||||
|
|
||||||
|
- 讯飞:[官方图片合规服务接入与鉴权说明](https://www.xfyun.cn/doc/nlp/ImageModeration/API.html)。注意 APIKey 对应 `access-key-id`,APISecret 对应 `access-key-secret`。
|
||||||
|
- 阿里云:[内容安全控制台](https://yundun.console.aliyun.com/?p=cts)、[开通服务与创建 RAM 用户的官方接入步骤](https://help.aliyun.com/zh/document_detail/467828.html)。按最小权限配置 `yundun-greenweb:DescribeUploadToken` 和 `yundun-greenweb:ImageModeration`,不需要给审核用户自有 OSS 全权限。
|
||||||
|
|
||||||
|
阿里云适配器通过官方临时上传令牌上传私有图片,再调用同步审核,**无需自建审核用 OSS Bucket**。它与 SkillHub 自己保存技能和头像的对象存储配置相互独立。当前将 `RiskLevel=none/low` 视为通过,`medium/high` 视为阻止,其中 `medium` 转人工复核是 SkillHub 的策略。空结果、未知值及接口失败均不放行。参考[官方接口说明](https://help.aliyun.com/document_detail/467829.html)。
|
||||||
|
|
||||||
|
### 2. 用私有环境文件提供凭证
|
||||||
|
|
||||||
|
下面使用仓库标准 `compose.release.yml` 的 `server` 服务。先按现有部署流程准备 `.env.release`、数据库及 SkillHub 对象存储,选用包含本功能的镜像版本。本节不会替换原部署设置。
|
||||||
|
|
||||||
|
在**仓库外**建立私有目录,将下面路径中的 `/etc/skillhub/private` 换成实际路径;目录权限建议 `700`,文件权限 `600`。不要把凭证文件提交到 Git。
|
||||||
|
|
||||||
|
标准 Compose 只透传它声明的环境变量;仅向 `.env.release` 添加新审核属性不会自动注入 `server`。以下通过额外 override 的 `env_file` 显式注入,原 Compose 模板没有内置这些凭证字段。
|
||||||
|
|
||||||
|
创建 `/etc/skillhub/private/avatar-moderation.env`,以下两种内容**二选一**。占位符需替换为实际凭证;`SPRING_APPLICATION_JSON` 必须是单行合法 JSON。
|
||||||
|
|
||||||
|
**讯飞:**
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SPRING_APPLICATION_JSON={"skillhub":{"avatar":{"moderation":{"provider":"xfyun","xfyun":{"app-id":"YOUR_APPID","access-key-id":"YOUR_APIKEY","access-key-secret":"YOUR_APISECRET"}}}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**阿里云:**
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SPRING_APPLICATION_JSON={"skillhub":{"avatar":{"moderation":{"provider":"aliyun","aliyun":{"access-key-id":"YOUR_ACCESS_KEY_ID","access-key-secret":"YOUR_ACCESS_KEY_SECRET"}}}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果部署已有 `SPRING_APPLICATION_JSON`,**将上述 `skillhub.avatar.moderation` 合并进原 JSON,不要覆盖原配置或设置重复变量**;其他 override 若通过 `environment` 定义同名变量,会优先于 `env_file`,也需同步合并。特殊字符应按 JSON 格式转义。
|
||||||
|
|
||||||
|
在同一私有目录创建 `compose.avatar.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
env_file:
|
||||||
|
- /etc/skillhub/private/avatar-moderation.env
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在仓库根目录执行。已有部署若使用额外 Compose 文件、`-p` 项目名或其他启动参数,保留这些参数,再追加头像 override,确保更新原服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make validate-release-config
|
||||||
|
docker compose --env-file .env.release \
|
||||||
|
-f compose.release.yml \
|
||||||
|
-f /etc/skillhub/private/compose.avatar.yml \
|
||||||
|
up -d --no-deps server
|
||||||
|
|
||||||
|
docker compose --env-file .env.release \
|
||||||
|
-f compose.release.yml \
|
||||||
|
-f /etc/skillhub/private/compose.avatar.yml \
|
||||||
|
ps server
|
||||||
|
```
|
||||||
|
|
||||||
|
上述更新命令适用于已运行的完整栈。首次部署完整栈时,将 `up -d --no-deps server` 改为 `up -d`。只执行 `restart` 不会重新加载修改后的容器环境,配置更新需要重新创建服务。后续更新也要保留同一 override。
|
||||||
|
|
||||||
|
正式 Spring 属性是 `skillhub.avatar.moderation.provider`、`skillhub.avatar.moderation.xfyun.app-id`、`skillhub.avatar.moderation.xfyun.access-key-id`、`skillhub.avatar.moderation.xfyun.access-key-secret`,以及 `skillhub.avatar.moderation.aliyun.access-key-id`、`skillhub.avatar.moderation.aliyun.access-key-secret`。其他部署平台可通过 Secret 注入同等属性。
|
||||||
|
|
||||||
|
::: warning 讯飞出口日志
|
||||||
|
讯飞官方接口将 `accessKeySecret` 放在请求 URL 参数中。应用不记录完整 URL 或异常原文,但部署方仍需关闭 HTTP 客户端调试日志,并确保出口代理、链路追踪及第三方日志不采集该请求 URL。不要把含凭证的 Compose 展开结果、容器环境或签名请求 URL 分享给其他人。
|
||||||
|
:::
|
||||||
|
|
||||||
|
### 3. 在后台开启,并验证真实调用
|
||||||
|
|
||||||
|
1. 确认 `server` 健康后,以超级管理员进入「系统配置」。
|
||||||
|
2. 开启机器审核,建议初次接入时保留人工审核开启。
|
||||||
|
3. 用技能所有者账号对一个非 `PRIVATE` 测试技能提交**新头像**。
|
||||||
|
4. 在更新页查看机器结果;开启人工时,审核员在技能审核详情中确认图片、机器状态并做出决定。
|
||||||
|
5. 对通过、阻止、异常分别检查:通过前旧公开头像不变,阻止或异常的新图片不可公开,人工拒绝后可见原因。不要为了测试把违规图片批准公开。
|
||||||
|
|
||||||
|
填写凭证、成功保存开关或应用健康,**都不能证明云服务调用成功**。后台检查的是配置是否齐全,真正的服务权限、网络、余额和接口响应必须通过新申请验证。已有申请不因开关开启而补跑机器审核。
|
||||||
|
|
||||||
|
接入验收现状:讯飞已通过真实接口验证通过(PASS)与阻止(BLOCK),并验证人工拒绝后保留旧公开头像;阿里云已进行接口契约、结果映射和异常处理的代码审查及模拟响应测试,**尚未真实账号联调**。这些结果不替代部署方对自己账号的连接验收。
|
||||||
|
|
||||||
|
## 修改开关与切换供应商
|
||||||
|
|
||||||
|
申请保存提交时的机器、人工开关和供应商快照,之后改配置不会回写已有申请。若要切换供应商,应更新私有配置并重新创建 `server`,再提交新申请:
|
||||||
|
|
||||||
|
- 已完成的机器结果不会自动重新检测。
|
||||||
|
- 旧候选仍需调用旧供应商时,切换后会得到 `ERROR`,不会偷偷改用新供应商;应重新提交以采用新供应商。
|
||||||
|
- 配置切换前先处理待审申请,避免把旧任务留在不可继续审核的状态。
|
||||||
|
|
||||||
|
被拒绝、替换或删除的候选图片私有保留 30 天后清理,文字审核记录继续保留。云审核服务自己的临时图片生命周期与 SkillHub 私有保留规则不同。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**开启失败提示供应商不可用?** 检查 `provider` 拼写、所选供应商凭证是否齐全,以及额外 override 是否实际用于启动 `server`。不要通过输出完整环境变量排查密钥。
|
||||||
|
|
||||||
|
**上传成功但一直异常?** 检查服务器能否访问对应云接口、凭证权限、服务是否开通及可用额度;阿里云还需要能访问其临时 OSS 上传端点。等待冷却后重试,不要把异常当通过。
|
||||||
|
|
||||||
|
**人工开关关闭就不审核技能包了吗?** 不是。这个开关只控制头像的人工策略,不改变原技能包审核流程。
|
||||||
|
|
||||||
|
**为什么其他人仍看到旧头像?** 新申请未通过时这是正常行为;所有者应到「我的技能」或更新页查看提交图片,公开详情只展示当前生效头像。
|
||||||
|
|
@ -91,6 +91,14 @@ npx @astron-team/skillhub@latest publish ./my-skill --namespace my-team
|
||||||
|
|
||||||
访问 `http://localhost:3000/dashboard/publish`,选择命名空间、上传 zip 文件、选择可见性后点击「发布」。
|
访问 `http://localhost:3000/dashboard/publish`,选择命名空间、上传 zip 文件、选择可见性后点击「发布」。
|
||||||
|
|
||||||
|
可选上传自定义技能头像:选择 PNG/JPEG、裁剪并等待私有上传成功,再提交技能。头像上传中或失败时不能提交;没有选择头像则正常提交并使用技能名首字母。更新技能时默认保留原头像,可选择替换或移除;移除仅在提交成功后生效,取消或提交失败不影响原头像。
|
||||||
|
|
||||||
|
技能包与头像同次提交时,共用一张技能审核申请,审核员一次批准或拒绝;通过前显示旧头像或首字母。文件上传成功不代表头像审核通过。仅需修改头像时,可从“我的技能”的“更新”入口操作,不需要发布新的技能版本;需要人工审核时仍进入统一技能审核队列。
|
||||||
|
|
||||||
|
机器判定头像违规时,新图保持私有。若开启人工审核,申请继续等待审核员决定,并非机器自动驳回;审核员可拒绝,认为误判时须填写理由才能批准。人工拒绝后,可在“我的技能”和更新页查看提交图片及拒绝原因。机器调用异常与违规不同,异常时须先重试,不能直接批准放行。
|
||||||
|
|
||||||
|
头像上传、审核状态及讯飞/阿里云接入步骤详见 [技能头像与图片审核接入](/guide/skill-avatars)。
|
||||||
|
|
||||||
4. **使用 REST API 发布**
|
4. **使用 REST API 发布**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
144
docs/skillhub/package-lock.json
generated
144
docs/skillhub/package-lock.json
generated
|
|
@ -288,13 +288,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.9",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
|
||||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
"integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.7"
|
"@babel/types": "^7.29.8"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
|
|
@ -304,9 +304,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.8",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -828,9 +828,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|
@ -1359,57 +1359,57 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-core": {
|
"node_modules/@vue/compiler-core": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz",
|
||||||
"integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
|
"integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.3",
|
"@babel/parser": "^7.29.9",
|
||||||
"@vue/shared": "3.5.34",
|
"@vue/shared": "3.5.44",
|
||||||
"entities": "^7.0.1",
|
"entities": "^7.0.1",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-dom": {
|
"node_modules/@vue/compiler-dom": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz",
|
||||||
"integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
|
"integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-core": "3.5.34",
|
"@vue/compiler-core": "3.5.44",
|
||||||
"@vue/shared": "3.5.34"
|
"@vue/shared": "3.5.44"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-sfc": {
|
"node_modules/@vue/compiler-sfc": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz",
|
||||||
"integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
|
"integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.3",
|
"@babel/parser": "^7.29.9",
|
||||||
"@vue/compiler-core": "3.5.34",
|
"@vue/compiler-core": "3.5.44",
|
||||||
"@vue/compiler-dom": "3.5.34",
|
"@vue/compiler-dom": "3.5.44",
|
||||||
"@vue/compiler-ssr": "3.5.34",
|
"@vue/compiler-ssr": "3.5.44",
|
||||||
"@vue/shared": "3.5.34",
|
"@vue/shared": "3.5.44",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.29",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-ssr": {
|
"node_modules/@vue/compiler-ssr": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz",
|
||||||
"integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
|
"integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.34",
|
"@vue/compiler-dom": "3.5.44",
|
||||||
"@vue/shared": "3.5.34"
|
"@vue/shared": "3.5.44"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-api": {
|
"node_modules/@vue/devtools-api": {
|
||||||
|
|
@ -1449,57 +1449,55 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz",
|
||||||
"integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
|
"integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "3.5.34"
|
"@vue/shared": "3.5.44"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-core": {
|
"node_modules/@vue/runtime-core": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz",
|
||||||
"integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
|
"integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.34",
|
"@vue/reactivity": "3.5.44",
|
||||||
"@vue/shared": "3.5.34"
|
"@vue/shared": "3.5.44"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-dom": {
|
"node_modules/@vue/runtime-dom": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz",
|
||||||
"integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
|
"integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.34",
|
"@vue/reactivity": "3.5.44",
|
||||||
"@vue/runtime-core": "3.5.34",
|
"@vue/runtime-core": "3.5.44",
|
||||||
"@vue/shared": "3.5.34",
|
"@vue/shared": "3.5.44",
|
||||||
"csstype": "^3.2.3"
|
"csstype": "^3.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/server-renderer": {
|
"node_modules/@vue/server-renderer": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz",
|
||||||
"integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
|
"integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-ssr": "3.5.34",
|
"@vue/compiler-ssr": "3.5.44",
|
||||||
"@vue/shared": "3.5.34"
|
"@vue/runtime-dom": "3.5.44",
|
||||||
},
|
"@vue/shared": "3.5.44"
|
||||||
"peerDependencies": {
|
|
||||||
"vue": "3.5.34"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/shared": {
|
"node_modules/@vue/shared": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz",
|
||||||
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
|
"integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|
@ -2278,9 +2276,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -2592,17 +2590,17 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz",
|
||||||
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
|
"integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.34",
|
"@vue/compiler-dom": "3.5.44",
|
||||||
"@vue/compiler-sfc": "3.5.34",
|
"@vue/compiler-sfc": "3.5.44",
|
||||||
"@vue/runtime-dom": "3.5.34",
|
"@vue/runtime-dom": "3.5.44",
|
||||||
"@vue/server-renderer": "3.5.34",
|
"@vue/server-renderer": "3.5.44",
|
||||||
"@vue/shared": "3.5.34"
|
"@vue/shared": "3.5.44"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "*"
|
"typescript": "*"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "^6.4.3",
|
"vite": "^6.4.3",
|
||||||
"postcss": "^8.5.23",
|
"postcss": "^8.5.23",
|
||||||
"esbuild": "^0.28.1"
|
"esbuild": "^0.28.1",
|
||||||
|
"vue": "^3.5.42",
|
||||||
|
"source-map-js": "^1.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-12
|
||||||
52
openspec/changes/add-namespace-invite-links/design.md
Normal file
52
openspec/changes/add-namespace-invite-links/design.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
## Context
|
||||||
|
|
||||||
|
现有 Namespace 成员只能由管理员直接添加。`NamespaceMemberService`、平台管理服务、生命周期服务和账号合并均会修改同一 Namespace 的状态或成员,但目前没有共同的 Namespace 行锁。前端已有 Session/CSRF、登录 returnTo 与 BASE_PATH 逻辑;当前 returnTo helper 会保留 fragment,所以 `/join` 必须先移除 Token 再触发登录跳转。
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. 每个 Namespace 只保存一条当前邀请:Token SHA-256、签发人和时间、过期时间、消费人和时间。原文只在生成响应中返回一次。作废删除当前邀请记录或等价地使其不可匹配,写审计;不删除已加入成员。
|
||||||
|
2. 涉及用户的写操作先按 userId 升序锁参与者用户行,再按 Namespace ID 升序锁目标 Namespace 行,最后读取或写入邀请与成员;账号合并沿用先锁用户的现有顺序,以免与成员或审计外键检查形成锁环。邀请创建、替换、作废以及带操作者审计的生命周期操作先锁操作者,再锁 Namespace;接受先锁接收者,再锁 Namespace。当前不写用户审计的 Namespace 删除只锁 Namespace。批量操作预先收集 ID、去重并排序。接受在锁内重检,成员创建、消费和审计原子提交。
|
||||||
|
3. 前端在 `/join` 入口同步读取 fragment,放入当前标签页 sessionStorage,保留 path/query 清除 fragment,再应用原登录逻辑。登录 `returnTo=/join` 不传 Token。分享链接复用 `resolvePublicRegistryUrl` 构造并兼容 BASE_PATH。
|
||||||
|
4. Preview/Accept 复用 Issue 的判定顺序:先验证 Token 为当前邀请,再查已有成员;已有成员返回 ALREADY_MEMBER。非成员必须面对 ACTIVE TEAM、未消费且未过期的邀请。失效统一 404,不区分随机、旧、作废、过期或已用 Token。Accept 锁前只按 Token 哈希查询标量 Namespace ID;取得 Namespace 锁后重新加载当前邀请并校验,不能沿用锁前已经进入 ORM 一级缓存的邀请实体,否则并发兑换可能都看到未消费状态。
|
||||||
|
5. 只有可用浏览器 Session 能创建、预览、接受、作废;现有账号准入不能由邀请绕过。浏览器请求使用既有 CSRF。创建/预览/接受/作废与缓存或日志配置均不得泄露原文和哈希。
|
||||||
|
6. 管理页面保留原直接添加成员入口,新增邀请按钮和单独作废按钮。仍可兑换的旧链接替换前先确认;剪贴板失败时展示本次生成的同一链接。
|
||||||
|
7. 正式路径沿用 Issue #911 的复数合同:`POST /api/v1/namespaces/{slug}/invitations` 创建或替换,`POST /api/v1/namespace-invitations/preview|accept` 预览及确认。状态查询和作废使用同一 `/namespaces/{slug}/invitations` 路径的 GET/DELETE;保留现有 `/api/web` 别名。Preview 返回 `namespaceSlug`、`namespaceDisplayName`、`namespaceAvatarUrl`、`outcome`、`expiresAt`;Accept 仅返回 `namespaceSlug`、`outcome`。无效邀请的本地化键为 `error.namespace.invitation.invalid`。
|
||||||
|
|
||||||
|
## Risks and verification
|
||||||
|
|
||||||
|
- 账号合并已先锁 UserAccount;邀请创建/替换/作废/接受、直接成员变更、平台成员管理及账号合并要按相同的用户后 Namespace 顺序加锁,避免与成员或审计外键检查形成环。
|
||||||
|
- 并发兑换、直接添加与兑换、替换/作废与兑换、冻结/归档/删除与兑换均需事务级验证。
|
||||||
|
- 根路径及子路径部署都要验证分享 URL、登录回跳和 fragment 清理。
|
||||||
|
- API Token、跨团队管理员、平台角色和禁用账号的越权路径需明确拒绝。
|
||||||
|
|
||||||
|
## 管理交互与具名片段(2026-10-13,已确认,实施中)
|
||||||
|
|
||||||
|
新分享地址为 `/join#invite=<token>`,兼容原 `/join#<token>`。进入页仍在登录跳转前同步捕获并清片段,Token不进入HTTP路径、查询、returnTo或日志。新片段无效时清旧暂存Token,无片段登录回跳才恢复暂存值。
|
||||||
|
|
||||||
|
命名空间卡片不平铺成员、邀请、作废、冻结、归档按钮;管理详情按技能/成员/审核/设置集中导航。成员页弹窗管理邀请,治理操作进入设置并保留旧权限与危险动作确认。公共空间技能浏览及旧成员/审核链接保持可用。平台审核权不等于成员管理权;GLOBAL不可冻结归档,但合法技能审核仍可用。
|
||||||
|
|
||||||
|
### 2026-10-14 人工核验交互修正
|
||||||
|
|
||||||
|
返回我的空间放在标题上方。普通成员不显示变更操作;角色编辑仍在角色列,保存/取消/移除使用操作列图标,提供可访问名称和悬停说明,保存成功或取消后恢复角色选择框焦点。审核状态用下拉筛选区分空间主导航。
|
||||||
|
|
||||||
|
邀请生成原文仍不存服务器;创建者以 userId+slug 隔离保存在当前标签页 sessionStorage。重开及刷新时先检查 active 再预览当前 Token,成功才显示;失效/替换时清除,网络失败不展示旧缓存但可重试。跨浏览器或标签页无法恢复时明确说明,替换须确认。存储不可用时仅保留当前弹窗的内存副本并提示立即复制。
|
||||||
|
|
||||||
|
邀请弹窗使用640px桌面宽度(手机为视口减32px),按邀请状态与链接、加入规则、管理操作分区。只在成功验证原邀请时展示实际到期时间;未知或失败不猜测到期时间,提供状态重试。复制为主要操作,替换和作废保留确认。
|
||||||
|
|
||||||
|
### 2026-10-14 邀请历史设计
|
||||||
|
|
||||||
|
已确认:邀请记录指一次成功生成链接及其生命周期结果;加入记录指账号通过该链接成功确认加入,非页面访问记录。每次生成独立记录;只有空间OWNER/ADMIN可以查看,不给平台角色增加旁路权限。REQ-INV-08/09/10已实现独立历史表及分页接口;前述当前邀请覆盖/作废删除只适用于当前链接凭证,业务历史不再被覆盖或删除。
|
||||||
|
|
||||||
|
旧版限制:升级前审计仅记录操作者、动作和空间ID,未携带邀请标识,因此无法可靠还原全部旧链接关联;新增审计关联独立邀请标识。已确认:空间存在期间保留,彻底删除空间时删除邀请业务历史而保留既有审计;仅迁入能确认的当前邀请并标注旧记录;保留曾经加入事实,账号变更不改写原邀请归属。已确认整体方案:保留当前邀请操作,并在弹窗下方直接展示只读分页历史,不加嵌套标签页。
|
||||||
|
|
||||||
|
#### 历史与当前邀请的技术边界
|
||||||
|
|
||||||
|
保留当前邀请哈希验证路径,新增独立业务历史及当前邀请关联标识。历史不存Token或哈希,每次生成写入独立记录;有效未消费的旧邀请被替换时标为已替换,已消费和已到期的旧记录保留原结果。成功加入、成员新增、邀请消费及相关审计必须同事务,不产生无成员的成功加入历史。自然过期由服务端时钟投影,业务流转校验在代码中,不新增SQL业务CHECK。
|
||||||
|
|
||||||
|
新迁移基于已实施V70–V73,不能为历史展示重写已应用迁移。只迁入当前邀请可证实字段并标记legacy,未知的过往姓名和关联不伪造;新记录以稳定账号ID关联,姓名以记录时快照辅助展示。空间彻底删除联动删除历史,冻结/归档保留历史。
|
||||||
|
|
||||||
|
新增历史查询沿用Session认证、禁止缓存及空间OWNER/ADMIN权限;每页数量有上限,按创建时间倒序和独立标识稳定排序。创建/预览/接受现有响应合同保持兼容。审计新增独立邀请标识关联,不包含任何凭证。
|
||||||
|
|
||||||
|
邀请历史列表网页每页5条并保持紧凑高度,数据保留规则不变。人员主要展示姓名快照,不以当前账号姓名替换历史;完整稳定账号ID作为悬停/可访问说明,未记录姓名时用短标识兜底,不增加邮箱采集或展示。
|
||||||
|
|
||||||
|
弹窗内采用紧凑操作尺寸:主要操作按钮32px、页码28px,收紧按钮与分页间距,日期和数量文字适当缩小;共用分页保留默认样式,仅邀请历史启用紧凑模式。640px弹窗宽度、每页5条及240px列表上限不变。
|
||||||
30
openspec/changes/add-namespace-invite-links/proposal.md
Normal file
30
openspec/changes/add-namespace-invite-links/proposal.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Namespace 管理员目前只能逐个选择现有用户添加成员。短期、单次邀请链接能让成员自行加入,同时让管理员控制链接失效和成员权限边界。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- ACTIVE TEAM 的 OWNER/ADMIN 可以生成一条 24 小时内有效、仅一名非成员可兑换的邀请链接;替换未使用链接前须确认。
|
||||||
|
- 管理员可以单独作废当前邀请,不需要生成新链接;不影响已经加入的成员。
|
||||||
|
- 持链接者先按现有注册、登录和账号准入策略获得可用账号,再看到空间、当前账号和 MEMBER 权限,明确确认后加入。
|
||||||
|
- Token 不存明文、不进 URL path/query 或登录回跳参数;创建、预览、兑换、作废均受权限及事务约束。
|
||||||
|
- 邀请兑换与成员及 Namespace 生命周期操作并发时保持一致,不出现重复成员或部分消费。
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `namespace-invitations`: 邀请创建、替换、作废、预览、确认加入、安全与并发规则。
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
无。
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Namespace 领域、成员管理、账号合并事务、数据库迁移、审计、Session/CSRF 接口和 OpenAPI 类型。
|
||||||
|
- Namespace 管理页面、邀请页、登录回跳与子路径部署、多语言文案及浏览器验证。
|
||||||
|
|
||||||
|
## 相对 Issue #911 的调整
|
||||||
|
|
||||||
|
原 Issue 把独立撤销入口列为范围外;本方案增加“作废邀请”按钮和相应后端动作。邀请不绕过现有账号准入策略。
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
让 Namespace 管理员使用可控的短期单次链接邀请新成员。
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: REQ-INV-01 管理员 SHALL 创建、替换及作废邀请
|
||||||
|
|
||||||
|
有权管理员 SHALL 可创建、替换及作废邀请。
|
||||||
|
|
||||||
|
只有 ACTIVE TEAM 的 OWNER/ADMIN 可以创建邀请;平台角色不能绕过成员角色限制。每个 Namespace 仅有一条当前邀请,24 小时内仅一名非成员可兑换一次。替换仍可用的邀请前须确认,创建响应仅在旧链接当时仍可兑换时标记 `replaced=true`;已消费或过期后的新建不标记替换。管理员可以单独作废当前邀请,且已加入成员不受影响。
|
||||||
|
|
||||||
|
#### Scenario: 替换未使用链接
|
||||||
|
- **WHEN** 管理员再次点击邀请且当前链接仍可兑换
|
||||||
|
- **THEN** 页面先说明旧链接将失效并要求确认
|
||||||
|
- **AND** 仅确认后生成新链接,旧链接立即失效
|
||||||
|
|
||||||
|
#### Scenario: 单独作废
|
||||||
|
- **WHEN** 管理员点击作废并确认
|
||||||
|
- **THEN** 当前链接立即失效,不会创建新链接或移除成员
|
||||||
|
|
||||||
|
### Requirement: REQ-INV-02 邀请 SHALL 服从现有账号准入
|
||||||
|
|
||||||
|
邀请 SHALL 服从现有账号准入规则。
|
||||||
|
|
||||||
|
邀请只授予目标 Namespace 的 MEMBER 权限,不使用户绕过现有注册、登录、外部身份准入和账号审批。接受者必须先拥有可用登录会话,看到目标 Namespace、当前账号和 MEMBER 可访问及发布资源的说明,再明确确认加入。页面加载不得自动兑换。
|
||||||
|
|
||||||
|
#### Scenario: 新用户未通过准入
|
||||||
|
- **WHEN** 持链接者无法按既有策略获得可用账号
|
||||||
|
- **THEN** 邀请不能使其进入目标 Namespace
|
||||||
|
|
||||||
|
#### Scenario: 已登录非成员
|
||||||
|
- **WHEN** 非成员打开有效链接
|
||||||
|
- **THEN** 页面展示 Namespace、账号和权限,只有点击确认才新增 MEMBER
|
||||||
|
|
||||||
|
### Requirement: REQ-INV-03 Token SHALL 避免意外泄露
|
||||||
|
|
||||||
|
Token SHALL 避免出现在日志、持久化明文和登录 URL 中。
|
||||||
|
|
||||||
|
Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。创建者页面可将原文保存在按账号和空间隔离的当前标签页 sessionStorage,供重开弹窗或刷新后恢复;展示前须重新确认服务端 active 状态并预览该 Token,失效或替换时清除。不得存入 localStorage 或数据库明文;其他标签页或设备无法恢复时明确说明,并保留确认替换入口。分享 URL 使用 `/join#invite=<token>`,页面立即移入当前标签页 sessionStorage 并清除 fragment;登录回跳只包含应用内 `/join`,不带 Token。创建、预览、兑换、作废响应禁止缓存;日志和审计不得包含原文或哈希。四类操作仅允许浏览器 Session,变更请求遵守 CSRF。
|
||||||
|
Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码;数据库仅保存 SHA-256 哈希。
|
||||||
|
|
||||||
|
#### Scenario: 经过登录回跳
|
||||||
|
- **WHEN** 未登录者打开邀请后登录
|
||||||
|
- **THEN** Token 不出现在登录回跳 URL,回到原标签页后才从 sessionStorage 恢复邀请
|
||||||
|
|
||||||
|
#### Scenario: 子路径部署
|
||||||
|
- **WHEN** 应用部署在 `/skillhub/`
|
||||||
|
- **THEN** 分享链接指向公开基地址下 `/skillhub/join#invite=<token>`,应用内 returnTo 仍为 `/join`
|
||||||
|
|
||||||
|
### Requirement: REQ-INV-04 服务端 SHALL 在接受时重新判断状态
|
||||||
|
|
||||||
|
服务端 SHALL 在接受邀请时重新判断全部状态。
|
||||||
|
|
||||||
|
预览不消费邀请。接受时重新检查 Token 是否仍为当前、用户是否已有成员关系、Namespace 是否 ACTIVE TEAM、邀请是否未消费且未过期。Token 匹配当前邀请且用户已是成员时返回 ALREADY_MEMBER,不再检查过期、消费或 Namespace 冻结归档状态,也不消费。未知、过期、替换、作废或已用 Token 对非成员返回统一失效响应。冻结或归档时非成员不能加入也不消费,恢复后仅在原有效期内可兑换。
|
||||||
|
统一失效响应使用 HTTP 404、envelope `code=404` 与 `data=null`;前端只按状态处理,不解析本地化文案。
|
||||||
|
|
||||||
|
#### Scenario: 预览后链接被替换
|
||||||
|
- **WHEN** 用户看到预览后管理员替换或作废了链接
|
||||||
|
- **THEN** 接受请求失败且不新增成员
|
||||||
|
|
||||||
|
#### Scenario: 已有成员打开当前链接
|
||||||
|
- **WHEN** Token 仍是当前邀请且当前用户已经是成员
|
||||||
|
- **THEN** 返回 ALREADY_MEMBER,直接进入 Namespace,不消耗邀请
|
||||||
|
|
||||||
|
#### Scenario: 被移除者重用已消费链接
|
||||||
|
- **WHEN** 曾兑换的成员后来被移除
|
||||||
|
- **THEN** 原链接仍保持已消费,不能再次加入
|
||||||
|
|
||||||
|
### Requirement: REQ-INV-05 并发操作 SHALL 保持成员和邀请原子一致
|
||||||
|
|
||||||
|
并发操作 SHALL 保持成员和邀请原子一致。
|
||||||
|
|
||||||
|
邀请创建、替换、作废和接受与成员添加/删除/转交、Namespace 冻结/归档/恢复/删除、账号合并在相同 Namespace 上须串行化。涉及用户和 Namespace 的写操作先按用户 ID 升序、再按 Namespace ID 升序加锁;创建/替换/作废及带操作者审计的生命周期操作锁操作者,接受锁接收者;当前不写用户审计的 Namespace 删除只锁 Namespace。接受时成员创建、邀请消费和审计同一事务完成;并发兑换最多一名非成员成功,不能产生“已消费但无成员”。批量涉及多个 Namespace 的操作按去重 ID 升序锁定。
|
||||||
|
|
||||||
|
#### Scenario: 两人同时兑换
|
||||||
|
- **WHEN** 两个非成员同时确认同一链接
|
||||||
|
- **THEN** 最多一人新增 MEMBER,另一人收到统一失效结果
|
||||||
|
|
||||||
|
#### Scenario: 管理员直接添加与邀请并发
|
||||||
|
- **WHEN** 管理员直接添加同一用户和该用户兑换邀请同时发生
|
||||||
|
- **THEN** 不出现未处理的唯一约束异常、重复成员或错误消费
|
||||||
|
|
||||||
|
### Requirement: REQ-INV-06 前端 SHALL 完成链接分享和状态闭环
|
||||||
|
|
||||||
|
前端 SHALL 完成链接分享、登录回跳和邀请状态闭环。
|
||||||
|
|
||||||
|
空间卡片收拢为进入空间的主要入口;空间管理详情按技能、成员、审核、设置组织,邀请集中在成员页弹窗,提供生成、替换确认、复制失败后的手动复制以及单独作废;冻结、归档及恢复等原有治理动作集中在设置,保留权限及确认。接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token 并自动使用 replace 导航进入空间,避免重复导航。加载状态使用可访问的 status 提示,终态失效使用 alert 提示。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。
|
||||||
|
|
||||||
|
#### Scenario: 剪贴板写入失败
|
||||||
|
- **WHEN** 创建成功但浏览器拒绝写剪贴板
|
||||||
|
- **THEN** 页面显示同一链接供手动复制,不再次生成邀请
|
||||||
|
|
||||||
|
#### Scenario: 网络错误
|
||||||
|
- **WHEN** 预览或接受遇到可重试网络错误
|
||||||
|
- **THEN** 页面保留 Token,用户可重试而无需新邀请
|
||||||
|
|
||||||
|
### Requirement: REQ-INV-07 API SHALL 使用 Issue #911 的路径与响应合同
|
||||||
|
|
||||||
|
API SHALL 使用 Issue #911 的路径与响应合同。
|
||||||
|
|
||||||
|
创建或替换使用 `POST /api/v1/namespaces/{slug}/invitations`,首次创建允许无请求体;有仍可兑换的当前邀请时须按已批准的确认流程传入 `replace=true`。状态查询和单独作废分别使用同一路径的 GET 与 DELETE。预览和接受分别使用 `POST /api/v1/namespace-invitations/preview` 与 `POST /api/v1/namespace-invitations/accept`,请求体均为 `{ "token": "..." }`;可保留 `/api/web` 别名。
|
||||||
|
|
||||||
|
创建数据仅包含 `token`、`expiresAt`、`replaced`。预览数据包含 `namespaceSlug`、`namespaceDisplayName`、`namespaceAvatarUrl`、`outcome`(`CONFIRM_REQUIRED` 或 `ALREADY_MEMBER`)和 `expiresAt`。接受数据仅包含 `namespaceSlug` 和 `outcome`(`JOINED` 或 `ALREADY_MEMBER`)。无效邀请使用 `error.namespace.invitation.invalid` 本地化键,中文为“邀请链接无效或已失效”,响应为 HTTP 404、envelope `code=404`、`data=null`。
|
||||||
|
|
||||||
|
#### Scenario: 首次创建和预览
|
||||||
|
- **WHEN** OWNER/ADMIN 首次创建邀请且不传请求体,非成员预览链接
|
||||||
|
- **THEN** 创建返回三字段,预览返回五字段且 `outcome=CONFIRM_REQUIRED`
|
||||||
|
|
||||||
|
#### Scenario: 接受与已有成员
|
||||||
|
- **WHEN** 非成员确认加入,或已有成员访问当前邀请
|
||||||
|
- **THEN** 接受分别返回 `JOINED` 或 `ALREADY_MEMBER`,且仅包含 `namespaceSlug` 和 `outcome`
|
||||||
|
|
||||||
|
#### Scenario: 旧邀请与无效新邀请
|
||||||
|
- **WHEN** 接收者打开旧的 `/join#<token>` 链接
|
||||||
|
- **THEN** 按原方式安全捕获并清除片段,保持兼容
|
||||||
|
- **WHEN** 新的具名片段格式无效
|
||||||
|
- **THEN** 清除先前暂存凭证,不能误用上一条邀请;无片段的登录回跳仍恢复合法暂存邀请
|
||||||
|
|
||||||
|
#### Scenario: 他人消费邀请后生成新链接
|
||||||
|
- **WHEN** 他人已消费邀请,而管理员页面还缓存旧的可兑换状态
|
||||||
|
- **THEN** 点击生成先刷新服务端状态,已消费或过期的邀请不误弹替换确认,仍可兑换的邀请必须确认
|
||||||
|
- **AND** 状态刷新失败时不沿用旧缓存生成或替换,显示可重试错误
|
||||||
|
|
||||||
|
#### Scenario: 重开邀请弹窗与成员页布局
|
||||||
|
- **WHEN** 同一账号在同一标签页重开弹窗或刷新成员页
|
||||||
|
- **THEN** 重新校验有效的已保存链接后显示,并提供复制;失效或替换的链接不展示
|
||||||
|
- **AND** 无保存链接时说明限制,不自动替换邀请
|
||||||
|
- **AND** 返回我的空间位于空间标题上方;无管理权限不显示成员变更按钮;有权限的角色保存、取消和移除放在操作列,图标有可访问名称及悬停提示
|
||||||
|
- **AND** 审核状态采用带标签的下拉筛选,与空间主导航区分
|
||||||
|
|
||||||
|
#### Scenario: 邀请弹窗提供完整状态及说明
|
||||||
|
- **WHEN** 有权管理员打开邀请弹窗
|
||||||
|
- **THEN** 显示目标空间及邀请有效/无有效邀请/加载/读取失败状态;可恢复原链接时显示实际到期时间及复制入口
|
||||||
|
- **AND** 说明24小时内单次使用、MEMBER权限、先登录及现有准入规则;替换和作废操作独立于复制,并继续要求确认
|
||||||
|
- **AND** 读取失败可重试;桌面弹窗适当增宽,手机仍可完整操作
|
||||||
|
|
||||||
|
### Requirement: REQ-INV-08 系统 SHALL 保留独立邀请记录
|
||||||
|
|
||||||
|
每次成功生成邀请 SHALL 创建一条具有独立标识的邀请记录,保存创建人、创建时间、到期时间及生命周期结果。再次生成、替换或作废 SHALL NOT 覆盖或删除之前的业务历史记录。记录可展示有效、已使用、已替换、已作废、已过期状态;过期状态按真实到期时间判定,不要求依赖定时任务才失效。
|
||||||
|
|
||||||
|
新成员通过邀请成功确认加入时 SHALL 将实际加入账号及加入时间准确关联至该次邀请记录。只打开或预览链接、不成功兑换或已有成员访问 SHALL NOT 被记录为新的加入成功。邀请记录 SHALL NOT 展示原始 Token 或 Token 哈希,历史记录不构成恢复已失效链接的入口。
|
||||||
|
|
||||||
|
本条为2026-10-14确认并实现的新增需求,执行验证以本次验收记录为准。空间存在期间持续保留邀请业务记录,不新增保留期配置;彻底删除空间时删除其邀请业务记录,操作审计按既有规则保留。升级仅迁入仍能准确读取的当前邀请并标注旧记录;不可可靠关联的更早审计不得拼造为邀请历史。用户退出或被移除后仍保留当时的成功加入事实;账号改名或合并不改写原邀请账号归属,记录不代表当前成员资格。
|
||||||
|
|
||||||
|
#### Scenario: 邀请已使用后再次生成
|
||||||
|
- **WHEN** 邀请A被张三成功使用,管理员随后生成邀请B
|
||||||
|
- **THEN** A保留已使用状态及张三的加入时间,B为新的独立记录
|
||||||
|
|
||||||
|
#### Scenario: 替换或作废邀请
|
||||||
|
- **WHEN** 管理员替换仍有效的邀请A,或作废邀请B
|
||||||
|
- **THEN** A或B保留各自已替换或已作废记录,不覆盖先前的加入记录
|
||||||
|
|
||||||
|
#### Scenario: 预览不记为加入
|
||||||
|
- **WHEN** 用户仅打开邀请页或预览链接,未成功确认加入
|
||||||
|
- **THEN** 不产生邀请加入成功记录
|
||||||
|
|
||||||
|
### Requirement: REQ-INV-09 邀请历史 SHALL 按空间管理权限限制访问
|
||||||
|
|
||||||
|
邀请历史页面和查询接口 SHALL 仅允许该空间OWNER或ADMIN访问。普通成员不得查看;平台角色不自动绕过空间成员角色规则。权限判定采用当前身份及当前空间角色,不能仅依赖前端隐藏入口。
|
||||||
|
|
||||||
|
#### Scenario: 普通成员或仅有平台角色的账号查看历史
|
||||||
|
- **WHEN** 当前账号不是该空间OWNER或ADMIN
|
||||||
|
- **THEN** 页面不提供历史查看入口,直接调用查询接口也不能获得记录
|
||||||
|
|
||||||
|
#### Scenario: 空间管理员查看历史
|
||||||
|
- **WHEN** 当前账号具有该空间OWNER或ADMIN角色
|
||||||
|
- **THEN** 可查看该空间邀请记录,不可据此查看其他空间记录
|
||||||
|
|
||||||
|
#### Scenario: 删除空间与历史保留
|
||||||
|
- **WHEN** 空间被彻底删除
|
||||||
|
- **THEN** 删除该空间的邀请业务记录,现有操作审计按原规则保留;归档或冻结不等同于彻底删除
|
||||||
|
|
||||||
|
#### Scenario: 升级迁入旧邀请
|
||||||
|
- **WHEN** 升级前存在可准确读取的当前邀请
|
||||||
|
- **THEN** 将其已知创建及消费信息迁入并标注为旧记录,不伪造更早记录的链接关联
|
||||||
|
|
||||||
|
#### Scenario: 加入后退出或被移除
|
||||||
|
- **WHEN** 通过邀请加入的用户后来退出或被管理员移除
|
||||||
|
- **THEN** 历史仍显示当时的加入事实,不据此授予或恢复成员权限
|
||||||
|
|
||||||
|
### Requirement: REQ-INV-10 邀请弹窗 SHALL 提供只读分页历史
|
||||||
|
|
||||||
|
现有邀请弹窗 SHALL 在上方保留当前链接及生成/替换/作废操作,下方直接显示邀请记录,不增加嵌套标签页。历史最新在前并分页,网页默认每页5条并限制列表高度;人员优先显示记录时保存的姓名或显示名称,不平铺完整user_id,完整标识保留在悬停提示及可访问说明中;无姓名的旧记录以缩短标识兜底。展示创建人及时间、到期时间、有效/已使用/已替换/已作废/已过期状态、实际加入人及加入时间;旧迁入记录明确标注。历史不显示原始链接、Token或哈希,不提供复制或恢复失效链接操作。列表支持加载、空列表、失败及重试状态;不同空间记录不得串用,成功变更邀请后刷新列表。
|
||||||
|
|
||||||
|
#### Scenario: 管理员在同一弹窗查看历史
|
||||||
|
- **WHEN** 空间OWNER/ADMIN打开邀请弹窗
|
||||||
|
- **THEN** 当前邀请操作与只读历史同时可访问;列表按最新在前分页,手机端可阅读和翻页
|
||||||
|
|
||||||
|
#### Scenario: 成功变更邀请刷新历史
|
||||||
|
- **WHEN** 管理员成功生成、替换或作废邀请
|
||||||
|
- **THEN** 列表刷新并展示对应记录和状态;取消确认不新增记录
|
||||||
|
|
||||||
|
#### Scenario: 历史读取失败
|
||||||
|
- **WHEN** 历史查询请求失败
|
||||||
|
- **THEN** 显示失败及重试入口,不能将其他空间缓存记录展示为当前空间历史
|
||||||
|
|
||||||
|
#### Scenario: 冻结或归档空间查看历史
|
||||||
|
- **WHEN** TEAM空间被冻结或归档,当前账号仍为该空间OWNER或ADMIN
|
||||||
|
- **THEN** 可打开只读历史入口,不开放当前邀请生成/替换/作废操作,历史读取不依赖变更权限
|
||||||
33
openspec/changes/add-namespace-invite-links/tasks.md
Normal file
33
openspec/changes/add-namespace-invite-links/tasks.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
## 1. 持久化与事务
|
||||||
|
|
||||||
|
- [x] 1.1 增加当前邀请表、Token 哈希、时效和审计迁移。
|
||||||
|
- [x] 1.2 实现 Namespace 根锁,并让成员、生命周期、删除和账号合并路径遵循统一锁顺序。
|
||||||
|
- [x] 1.3 实现创建/替换/作废/预览/接受,接受时原子新增成员、消费邀请与写审计。
|
||||||
|
|
||||||
|
## 2. API 与页面
|
||||||
|
|
||||||
|
- [x] 2.1 新增仅 Session 可调用且受 CSRF 保护的邀请接口,重新生成 OpenAPI 类型。
|
||||||
|
- [x] 2.2 在成员管理入口添加邀请和作废动作,替换前确认,复制失败展示原链接。
|
||||||
|
- [x] 2.3 实现 `/join` 的 fragment 捕获、同标签页登录回跳、确认加入和失效状态;覆盖 BASE_PATH 与三语文案。
|
||||||
|
|
||||||
|
## 3. 验证与交付
|
||||||
|
|
||||||
|
- [x] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。
|
||||||
|
- [x] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。
|
||||||
|
- [x] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。
|
||||||
|
|
||||||
|
验证结果以 PR #947 的检查和交付验收记录为准。真实讯飞/阿里云计费审核接口未发起调用。
|
||||||
|
|
||||||
|
## 4. 人工反馈后的管理交互(2026-10-13,实施中)
|
||||||
|
|
||||||
|
- [ ] 4.1 生成具名邀请片段并兼容旧链接,覆盖无效片段、暂存恢复和BASE_PATH。
|
||||||
|
- [ ] 4.2 收拢空间卡片,提供管理详情导航,将邀请集中成员、治理集中设置。
|
||||||
|
- [ ] 4.3 验证普通成员/团队管理/平台审核权限及邀请完整浏览器流程,更新唯一预览。
|
||||||
|
|
||||||
|
|
||||||
|
## 5. 邀请历史(2026-10-14,方案已确认,实施中)
|
||||||
|
|
||||||
|
- [x] 5.1 确认每次生成独立记录、真实加入关联和空间OWNER/ADMIN查看权限,写入REQ-INV-08/09。
|
||||||
|
- [x] 5.2 确认保存期限、旧数据、成员/账号/空间变更边界及交互,补齐设计和REQ-INV-10验收标准。
|
||||||
|
- [x] 5.3 实现历史持久化、生命周期事务关联、权限接口及弹窗历史展示(含冻结/归档只读入口)。
|
||||||
|
- [x] 5.4 验证旧数据、邀请状态、并发加入、访问权限、分页及本地浏览器流程。
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-12
|
||||||
77
openspec/changes/add-skill-avatars-and-moderation/design.md
Normal file
77
openspec/changes/add-skill-avatars-and-moderation/design.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
## Context
|
||||||
|
|
||||||
|
当前 Skill 无头像字段。详情、搜索和“我的技能”由不同查询路径投影,Web 卡片与详情也没有统一头像组件。`ObjectStorageService` 支持写入、读取和删除,但本地实现不产生预签名 URL。现有 ReviewTask 只处理 SkillVersion/SuiteVersion;头像不能伪装成版本审核。V68 已提供 versioned `system_setting`。
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. 为 Skill 保存当前已激活头像引用;另建头像候选及决定记录。候选包含所属 Skill、提交者、私有对象键、内容哈希、目标可见范围、提交时审核策略快照、机器结果、人工决定、状态和时间。所有状态转移按候选 ID 和当前候选 ID 条件更新,防止迟到结果覆盖。
|
||||||
|
2. 头像人工决定复用现有 ReviewTask、技能审核队列与 ReviewPermissionChecker。技能包与头像同次提交时关联同一版本审核任务;单改头像使用元数据审核任务,不创建 SkillVersion。头像候选保留图片、机器结果和审计,不作为另一套人工审核流程。上传 API 显式校验 `skill.ownerId == actor`;审核权不授予代传权。
|
||||||
|
3. 图片通过受控 GET 接口提供。私人和待审对象必须鉴权,当前公开头像可提供稳定、无时效的访问 URL;服务端不得将对象存储临时签名地址持久化。公开范围变更之前须重新判断头像是否已获目标范围许可,避免私人图片借 Skill 可见性变化直接泄露。
|
||||||
|
4. 上传只接受最终裁剪图,服务端独立解码、校验、重新编码并去掉元数据。上限和格式以现有上传约定及测试验证后在实现中固定,避免前端校验成为唯一防线。
|
||||||
|
5. `system_setting` 新增独立头像审核键,仅保存机器和人工开关。部署配置只允许一个供应商及其 Secret;后台不展示供应商或 Secret。适配器在服务端主动调用,供应商异常保留候选和重试,不自动通过。供应商实现与 API 参数在接入时以官方文档验证。
|
||||||
|
6. 开关在提交候选时快照。PRIVATE 且仅所有者可见直接激活;其他候选按照快照执行。机器通过且人工关闭可激活;机器阻止时允许例外申诉;人工开启时有权审核员可改判并留理由。
|
||||||
|
7. 替换待审候选时旧候选变为 SUPERSEDED。删除和管理员撤下立即取消当前头像并恢复兜底。失效图片保留私有 30 天,再由有界清理任务删除;审计记录不随图片删除。
|
||||||
|
8. `SkillSummaryResponse` 的所有映射路径、详情 DTO 与生成类型包含当前头像 URL;Web 使用一个头像/兜底组件,所有者操作和审核队列分别展示其可执行动作。
|
||||||
|
|
||||||
|
## Risks and verification
|
||||||
|
|
||||||
|
- 私人候选和已发布头像的读取权限用非所有者、匿名和跨团队请求验证。
|
||||||
|
- 上传、更换、审核和撤下并发时验证当前候选不被旧结果激活。
|
||||||
|
- V68 后迁移在已有数据上通过;旧 Skill 无头像时无需回填。
|
||||||
|
- 服务端图片解码处理需设置资源上限;机器供应商凭证不得进入日志、数据库或前端。
|
||||||
|
- 原 Issue 的“机器不得直接拒绝”与本轮方案不同。机器单开模式的质量和误判处理需在运行验证中单列,不以外部 API 返回成功代替有效审核验证。
|
||||||
|
|
||||||
|
|
||||||
|
## 已确认的发布交互调整(2026-10-13,已实施,浏览器验收通过)
|
||||||
|
|
||||||
|
本节补充原实现,需求以 `specs/skill-avatars/spec.md` 的 REQ-AV-09/10/11 为准。
|
||||||
|
|
||||||
|
- 新增和更新页面使用可选自定义上传,不提供预设图标;更新默认保留,支持替换及移除。
|
||||||
|
- “我的技能”提供单独修改头像入口,不要求新版本;详情页保留展示与审核状态,移除完整上传表单。统一复用裁剪和上传组件。
|
||||||
|
- 先私有上传、后业务提交:新增技能尚无 Skill ID,临时上传不能提前创建已绑定的头像申请。上传引用绑定提交者并校验可用性,临时阶段不改变当前头像或待审申请。
|
||||||
|
- 技能提交成功与头像引用关联须共同成功。失败不改变当前头像或已有候选;数据库关联与业务状态共同提交,外部对象写入提前完成,不把对象存储或供应商请求放入长事务。重复提交不重复创建申请。
|
||||||
|
- 头像审核在关联提交后执行,遵循原可见范围和策略规则;机器和人工开关在候选创建时快照。上传成功不意味着审核通过,技能与头像分别展示审核状态。
|
||||||
|
- 更新页移除仅在提交成功后执行,立即恢复兜底并使待审候选失效,申请历史和失效图片保留策略不变。
|
||||||
|
- 未关联临时图片始终私有,按有界任务定期清理;关联与清理并发须保证不会删除已绑定图片。临时引用使用独立 `skill_avatar_upload` 表,24 小时过期;上传接口为 `/api/web/skills/avatar-uploads`,提交技能携带 `avatarAction`(默认 KEEP,可选 REPLACE/REMOVE)和替换时的 `avatarUploadId`。单独维护使用现有头像路径的 JSON 提交;更新携带 `expectedSlug` 校验技能包目标。清理和引用消费均锁定临时上传记录,发布与独立修改均采用技能→上传记录的锁顺序。不引入业务状态 SQL CHECK 约束。
|
||||||
|
- 唯一机器供应商的部署选择、后台两个审核开关、PRIVATE 直接使用规则保持不变。
|
||||||
|
|
||||||
|
原验收只覆盖详情页独立提交流程,不能证明上述新增/更新流程;本轮新增/更新及独立维护流程按对应测试和浏览器验收重新验证。真实供应商调用仍未验证。
|
||||||
|
|
||||||
|
同浏览器的发布、独立维护和审核决定成功后,须等待刷新技能展示、对应所有者头像状态、头像审核队列及技能内容审核队列缓存;不以等待缓存过期替代状态更新。回归以真实 QueryClient 下旧状态变为提交后的状态为 Oracle,并验证无关技能的所有者缓存不被失效。
|
||||||
|
|
||||||
|
## 裁剪组件选型调整(2026-10-13)
|
||||||
|
|
||||||
|
头像文件选择复用已有 `react-dropzone`,裁剪交互采用 `react-image-crop`(ISC,兼容项目 React 版本)。方形选区的拖动、缩放选区、触屏和键盘行为交由开源组件处理,移除自写居中裁剪与缩放交互。仅保留选区图像导出及私有上传/提交状态衔接,不改变审核、引用消费、保留/替换/移除规则。组件适配须验证非居中选区的实际导出结果、取消与迟到响应隔离,以及新增/更新/独立维护入口。
|
||||||
|
|
||||||
|
## 头像入口与弹窗简化(2026-10-13,用户人工反馈)
|
||||||
|
|
||||||
|
- 详情页仅保留标题旁头像,删除专门头像状态卡片、重复候选图、供应商开关说明及已批准审核理由。审核治理能力不因展示简化而移除。
|
||||||
|
- 我的技能自绘列表补齐名称旁头像,删除独立“管理头像”按钮与入口;统一“更新”页同时支持仅修改头像或上传新技能包。无技能包时只提交既有头像维护接口,不新增版本;有包时保留原子发布/头像关联流程。新建及驳回重提仍必须提供技能包。
|
||||||
|
- 使用已有 Tailwind CSS 视觉样式及 Radix Dialog 封装承载 React Image Crop,不引入新的整体 UI 框架。父表单为64像素头像和简短操作;裁剪弹窗画布有界、自适应,错误和重试在弹窗内。
|
||||||
|
- 弹窗取消/关闭/ESC恢复打开前草稿,上传确认成功后关闭弹窗并展示小预览;成功上传仍需父页面最终提交。更新页仅展示所有者需要处理的待审/失败/驳回信息及原有重试、申诉动作,保持治理闭环。
|
||||||
|
|
||||||
|
组件接入方式:仓库已有 `web/components.json`,本地 `shared/ui/Button`、`Dialog` 沿用 shadcn 的源码分发/组件组合方式,底层为 Radix 与 Tailwind。当前为 Tailwind 3 和旧 `default` 风格;本轮复用现有封装与主题,不重新执行 init,也不覆盖已有 Portal/焦点/主题定制。头像选择由本地预览、Button、Dialog 和 React Image Crop 组合,shadcn Avatar 的展示职责与图片裁剪/上传业务分开。
|
||||||
|
|
||||||
|
删除头像后的展示闭环:移除成功时,除当前头像状态外,还须等待已缓存但未挂载的技能列表/详情更新,避免返回列表短暂读取已删除的头像地址。本轮仅 REMOVE 使用 `refetchType: all`,KEEP/REPLACE沿用 active 刷新;回归用无 observer 的真实 QueryClient 数据验证刷新完成后再导航。弹窗高度约束设在 ReactCrop 根元素,以使库的图片继承明确的 `min(320px,45vh)`,并用实际浏览器测量验证。
|
||||||
|
|
||||||
|
## 统一技能审核与单改头像(2026-10-13,已确认,实施中)
|
||||||
|
|
||||||
|
本节取代以上历史实现中的“技能与头像独立人工审核”决定。
|
||||||
|
|
||||||
|
- 包与头像替换同次提交只生成一张技能审核申请;详情同时显示技能内容、当前头像与候选图。机器结果是该申请的审批条件,不是另一条人工审核流程。批准在同一事务发布版本及激活头像;驳回两者均不发布。
|
||||||
|
- 包与移除头像同次提交也在批准时共同生效;驳回保留原头像。单独移除仍无需审核,提交成功即生效。
|
||||||
|
- 单改头像不创建版本;需人工审核时以 SKILL_METADATA 进入同一技能审核队列,复用详情和批准/驳回动作。无人工审核时继续按头像策略直接使用或机器判定;PRIVATE规则保持。
|
||||||
|
- 权限完全复用技能审核规则:平台技能管理员/超级管理员可审核团队和全局申请,团队 OWNER/ADMIN 只能审核其空间;自审限制保持原技能审核语义。
|
||||||
|
- 新候选替换旧候选仍保留历史。关联旧候选的任务不能跳过头像单独批准内容;若候选已失效,整条批准失败,使用现有撤回/重提流程处理。机器未完成、异常或旧结果不能发布候选。
|
||||||
|
- 扩大可见范围所需的头像决定在同一技能申请中完成,不在批准内容后另建人工头像申请。部署供应商、密钥、审核开关及30天私有图片保留策略不变。
|
||||||
|
- 迁移保留旧数据和审计,兼容原版本/套件审核任务。业务状态约束放代码与测试,不增加 SQL CHECK。
|
||||||
|
|
||||||
|
申请留存边界:被替换/移除的候选及提交、决定、失效文字审计继续保留,关联的待审头像元数据任务可按既有撤回方式从活动队列删除,审计保留原任务关联。不得将撤回伪装为人工驳回。本期不扩展 ReviewTask 状态枚举或改变原技能包撤回语义。
|
||||||
|
|
||||||
|
## PR Review 的机器审计收敛(2026-10-13,实施中)
|
||||||
|
|
||||||
|
现有云审核不能证明供应商模型版本不可变,取消不同候选之间的通过/阻止缓存复用。保留单候选的尝试租约及迟到结果保护;候选记录最新实际调用上下文,各次审计保留当次上下文与结果。记录供应商服务/API版本和内部风险映射策略revision,模型版本未知明确留空。历史候选只标未知,不用当前配置补造历史证据。不引入新后台开关或供应商调用协议。
|
||||||
|
|
||||||
|
原Issue的本地Scanner、模型评测及不可变模型制品供应链继续在原Issue跟踪。本PR只引用两条Issue,不自动关闭;四种头像审核开关和邀请确认/作废是维护者已确认的范围变化,PR说明须明确,并在合并前取得一致验收约定。
|
||||||
|
|
||||||
|
迁移合并顺序:本分支未发布迁移编号统一为 V70 头像、V71 邀请、V72 临时上传、V73 统一审核及机器证据,给PR #935 的 V69 authoring 留号。合并前须先合 #935 或与其维护者协调重新编号;不能在 #947 已应用 V73 后假设默认 Flyway 会补跑未来 V69。本机旧预览保留数据的编号映射仅是未发布测试迁移兼容,不替代目标主线更新后的空库/升级验证。
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Skill 目前没有稳定的图像标识,卡片和详情难以快速区分。上传图片后还需要保证文件安全、访问权限和可配置的内容审核,不让待审图片提前公开。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 为个人、团队和全局 Skill 增加由 Skill 所有者维护的可选头像;所有展示位置使用一致的首字兜底。
|
||||||
|
- 在新增/更新技能页面支持可选自定义头像与裁剪,先私有上传成功后随业务提交关联;更新默认保留,可替换或移除,不提供预设图标。
|
||||||
|
- “我的技能”支持单独修改头像,无需新版本;详情页仅在标题旁展示头像。头像属于 Skill,未明确替换或移除时不随版本更新重置。
|
||||||
|
- 临时图片与正式申请分离,未关联图片定期清理;技能包与头像同次提交只生成一张技能审核申请,单改头像保留且无需新版本。
|
||||||
|
- 按实际可见范围处理审核:仅本人可见的 PRIVATE 头像可直接使用;团队可见或公开头像按部署方的机器、人工审核开关处理;全局头像由平台管理员审核。
|
||||||
|
- 部署时选择唯一机器审核供应商并配置凭证,提供讯飞和阿里云适配;后台仅管理审核开关。机器失败不放行,机器误判可申请人工复核。
|
||||||
|
- 仅 Skill 所有者可提交、更换或主动删除;管理员可以审核、复核和撤下违规头像。旧候选失效后不能被迟到的审核结果激活。
|
||||||
|
- 被驳回、被替换或主动删除的图片私有保留 30 天后清理,文字审计记录保留。
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `skill-avatars`: Skill 头像、权限、候选生命周期、审核策略、访问和清理。
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
无。
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Skill 领域、数据库迁移、对象存储、公开摘要和详情 API、OpenAPI 生成类型。
|
||||||
|
- 系统配置、机器审核供应商配置、团队及平台审核入口、审计和清理任务。
|
||||||
|
- Skill 卡片、详情、所有者操作和审核页面;后端、前端与浏览器验证。
|
||||||
|
|
||||||
|
## 相对 Issue #910 的调整
|
||||||
|
|
||||||
|
原 Issue 建议机器审核首版仅作风险分流。本方案允许部署方单独开启机器审核并据此自动发布或阻止发布,同时提供误判申诉。实现和验收必须明确这一差异、供应商故障时不放行,以及机器判定质量的回归验证。
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
为 Skill 提供安全、一致、可治理的自定义头像。
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: REQ-AV-01 Skill SHALL 有可选头像和统一兜底
|
||||||
|
|
||||||
|
Skill SHALL 支持可选头像和统一兜底。
|
||||||
|
|
||||||
|
头像属于 Skill 容器而非 SkillVersion。摘要与详情只返回当前已激活的稳定头像访问地址;无头像时所有 Skill 展示位置使用展示名称首字符的大写兜底。新版本发布未明确选择替换或移除头像时不得重置头像。
|
||||||
|
|
||||||
|
#### Scenario: 旧 Skill 没有头像
|
||||||
|
- **WHEN** 客户端读取无头像的 Skill
|
||||||
|
- **THEN** 不需数据回填即可展示一致的首字兜底
|
||||||
|
|
||||||
|
#### Scenario: 发布新版本
|
||||||
|
- **WHEN** 已有头像的 Skill 发布新版本,且未选择替换或移除头像
|
||||||
|
- **THEN** 原头像保持不变
|
||||||
|
|
||||||
|
### Requirement: REQ-AV-02 仅 Skill 所有者 SHALL 维护头像
|
||||||
|
|
||||||
|
头像维护接口 SHALL 仅允许 Skill 所有者调用。
|
||||||
|
|
||||||
|
只有当前 Skill 所有者可提交、更换或主动删除头像。团队及平台管理员的审核权不授予代传权;有权管理员可以撤下违规头像并记录原因。所有者停用时本期不新增所有权交接或代传机制。
|
||||||
|
|
||||||
|
#### Scenario: 管理员不是所有者
|
||||||
|
- **WHEN** 团队或平台管理员尝试上传、替换或主动删除别人的 Skill 头像
|
||||||
|
- **THEN** 服务端拒绝,不依赖前端隐藏按钮
|
||||||
|
|
||||||
|
#### Scenario: 所有者停用
|
||||||
|
- **WHEN** 头像已发布而所有者无法操作
|
||||||
|
- **THEN** 有权管理员仍可撤下该头像并恢复兜底,但不能代传新头像
|
||||||
|
|
||||||
|
### Requirement: REQ-AV-03 服务端 SHALL 验证并私存最终裁剪图片
|
||||||
|
|
||||||
|
服务端 SHALL 验证并私存最终裁剪图片。
|
||||||
|
|
||||||
|
Web 端提供正方形裁剪且只上传最终裁剪图。服务端独立校验文件签名、尺寸、字节与像素限制,解码、重新编码并去除元数据。待审候选只在私有存储中,且只能由提交者和有权审核员读取;不得持久化临时预签名 URL 作为头像地址。
|
||||||
|
|
||||||
|
#### Scenario: 伪造图片或畸形文件
|
||||||
|
- **WHEN** 上传的文件签名、解码结果或大小不符合限制
|
||||||
|
- **THEN** 服务端拒绝创建候选,不公开文件
|
||||||
|
|
||||||
|
#### Scenario: 候选待审
|
||||||
|
- **WHEN** 新候选尚未通过所需审核
|
||||||
|
- **THEN** 公众继续看到原头像或兜底,无法读取候选图片
|
||||||
|
|
||||||
|
### Requirement: REQ-AV-04 审核 SHALL 依据实际可见范围
|
||||||
|
|
||||||
|
头像审核 SHALL 依据实际可见范围执行。
|
||||||
|
|
||||||
|
PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使用。NAMESPACE_ONLY 或 PUBLIC 的团队 Skill 使用团队范围策略,团队管理员或有技能审核权限的平台管理员可审核;全局 Skill 使用全局范围策略和平台管理员人工审核。所有人工权限及自审限制沿用现有技能审核规则。个人 Namespace 当前按 TEAM 处理,不能仅凭名称免审。可见范围扩大时,未经过目标范围审核的头像不得直接对新受众公开。
|
||||||
|
|
||||||
|
#### Scenario: 个人私有 Skill 扩大可见范围
|
||||||
|
- **WHEN** 原来仅本人可见的 Skill 变为团队可见或公开
|
||||||
|
- **THEN** 原头像进入目标范围审核,审核完成前新受众只能看到兜底或先前已获目标范围批准的头像
|
||||||
|
|
||||||
|
#### Scenario: 团队 Skill 提升至全局
|
||||||
|
- **WHEN** 团队 Skill 派生全局 Skill
|
||||||
|
- **THEN** 团队头像不会自动成为全局头像;全局候选按全局策略审核
|
||||||
|
|
||||||
|
### Requirement: REQ-AV-05 部署方 SHALL 控制两项审核开关
|
||||||
|
|
||||||
|
部署方 SHALL 可独立控制机器和人工审核开关。
|
||||||
|
|
||||||
|
机器与人工审核开关保存于独立的系统设置键,后台仅显示和修改这两个开关;唯一机器供应商及凭证由部署配置确定,凭证不得写入系统设置或回显。机器开关开启而供应商未正确配置时应拒绝设置。
|
||||||
|
|
||||||
|
#### Scenario: 四种策略
|
||||||
|
- **WHEN** 机器/人工为关/开、开/开、开/关、关/关
|
||||||
|
- **THEN** 单改头像分别执行人工通过、机器后人工、机器通过自动发布且判违规不发布、直接发布;带技能包时无论头像开关如何,均须等待原有内容审核共同发布
|
||||||
|
- **AND** 关闭全部审核时后台显示风险提示
|
||||||
|
|
||||||
|
### Requirement: REQ-AV-06 机器审核 SHALL 失败关闭并支持复核
|
||||||
|
|
||||||
|
机器审核 SHALL 在失败时保持候选私有,并支持例外复核。
|
||||||
|
|
||||||
|
服务端主动调用部署时确定的讯飞或阿里云适配器,首版不接受外部结果回调。机器调用失败、超时或结果不明不能视为通过;候选保持私有并可重试。机器判违规且人工审核开启时审核员可记理由改判;仅机器开启时提交者可申请例外人工复核,不把全部申请放入人工队列。
|
||||||
|
本期云审核提供方未提供可证明不可变的模型版本,不跨候选复用机器通过或阻止结果;每条新候选重新检测。单候选调用的尝试锁、幂等及迟到结果保护保持。候选和每次机器尝试须记录实际供应商、服务标识、API版本、内部判定策略 revision 及结果;未知模型版本明确为空,不能以 SDK 或 API 版本替代。重试时策略变化必须记录该次真实上下文,不能标记成原策略结果;新目标范围的人工决定也不能复用。
|
||||||
|
|
||||||
|
#### Scenario: 供应商超时
|
||||||
|
- **WHEN** 机器审核超时
|
||||||
|
- **THEN** 候选不公开,系统保留可重试状态
|
||||||
|
|
||||||
|
#### Scenario: 机器误判
|
||||||
|
- **WHEN** 提交者对机器阻止的候选申请复核
|
||||||
|
- **THEN** 相应范围的管理员可以批准或驳回,记录决定人和理由
|
||||||
|
|
||||||
|
### Requirement: REQ-AV-07 候选状态 SHALL 防止旧结果激活
|
||||||
|
|
||||||
|
候选状态 SHALL 防止已失效申请被迟到结果激活。
|
||||||
|
|
||||||
|
提交候选时固定当时的审核策略。一个 Skill 的新申请替换待审旧申请,旧申请保留为已被替换状态;迟到的机器结果或审核动作不得激活它。自审沿用既有技能审核规则,团队 OWNER/ADMIN 和超级管理员按原权限处理,平台 SKILL_ADMIN 不因头像申请而获得额外自审权;提交和决定均须留痕。
|
||||||
|
|
||||||
|
#### Scenario: 旧机器结果晚到
|
||||||
|
- **WHEN** 新候选已经替换旧候选,旧候选的机器结果才返回
|
||||||
|
- **THEN** 旧候选保持失效,当前头像不受其影响
|
||||||
|
|
||||||
|
### Requirement: REQ-AV-08 删除与撤下 SHALL 恢复兜底并保留审计
|
||||||
|
|
||||||
|
删除与撤下 SHALL 立即恢复兜底并保留审计。
|
||||||
|
|
||||||
|
所有者主动删除当前头像无需审核;管理员违规撤下时须记录操作者和原因。两种动作均立即恢复兜底。被驳回、被替换或主动删除的图片私有保留 30 天后清理,文字申请与审核记录继续保留;确认违法的图片可提前清理。
|
||||||
|
|
||||||
|
#### Scenario: 管理员撤下
|
||||||
|
- **WHEN** 有权管理员撤下违规的已发布头像
|
||||||
|
- **THEN** 对外立即显示兜底,审计保留原因与操作者
|
||||||
|
|
||||||
|
### Requirement: REQ-AV-09 前后端 SHALL 提供可理解的操作闭环
|
||||||
|
|
||||||
|
前后端 SHALL 提供可理解的头像提交与审核闭环。
|
||||||
|
|
||||||
|
新增和更新 Skill 页面提供可选自定义头像、方形裁剪、上传预览及上传状态,不提供预设图标选择;未上传时使用首字母兜底。更新页面展示当前头像并提供保留、替换、移除三种动作,默认保留。“我的技能”仅保留统一的“更新”入口,列表在技能名称旁显示头像。更新页复用同一套自定义头像组件,可只提交头像修改而不要求上传技能包或发布新的 SkillVersion;上传技能包时仍按原流程发布版本。选择图片后在项目已有弹窗内裁剪,父表单仅保留小头像预览与必要操作,不展开大图。关闭、取消或按 ESC 退出裁剪时恢复打开前的头像草稿。详情页仅在标题旁显示当前头像,不再显示单独的头像管理面板、重复图片、机器开关说明或已批准理由;所有者需要处理的头像待审状态和驳回原因集中在更新页。技能包与头像同次提交时只展示一张技能审核申请及其内容、头像检查状态,一次批准或驳回共同生效。单独修改头像仍支持,需人工审核时进入相同技能审核队列,不另设头像审核入口。审核员可以读取其管辖范围候选并处理,机器单开时可以处理例外申诉。所有展示 Skill 身份的位置使用同一头像与兜底组件。
|
||||||
|
|
||||||
|
#### Scenario: 申请等待审核
|
||||||
|
- **WHEN** 所有者上传候选后需人工决定
|
||||||
|
- **THEN** 页面展示待审状态且已发布头像维持不变
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### Scenario: 裁剪取消
|
||||||
|
- **WHEN** 所有者已选择头像草稿,再选择另一张图并取消裁剪弹窗
|
||||||
|
- **THEN** 恢复弹窗打开前的草稿,包括原临时上传引用或移除选择
|
||||||
|
- **AND** 迟到的上传结果不得替换恢复后的草稿
|
||||||
|
|
||||||
|
### Requirement: REQ-AV-10 头像 SHALL 先私有上传再随提交关联
|
||||||
|
|
||||||
|
新增、更新或单独修改头像时,客户端 SHALL 先裁剪并上传图片到私有存储,上传成功后取得可用于提交的临时引用。选择了新图片但仍在上传或上传失败时,客户端不得提交该次操作;服务端亦须校验引用对应的文件上传成功、未过期、属于当前提交者且不可越权关联。未选择头像的新技能可正常提交。
|
||||||
|
|
||||||
|
上传成功不等于审核通过。临时上传不改变 Skill 当前头像、待审申请或公开状态;技能提交成功时才关联上传引用并创建头像候选,按当时实际可见范围及审核配置执行;带包的候选绑定同一技能审核,单改头像才按策略创建元数据任务或直接执行,PRIVATE 仍沿用直接使用规则。需要的机器或人工审核在提交后进行,不要求用户停留在发布表单等待审核。关联失败不得留下已成功提交技能但丢失本次头像操作的部分结果;重复提交同一引用不得产生重复申请。
|
||||||
|
|
||||||
|
未关联、过期或用户放弃提交的临时图片保持私有并定期清理;清理不得删除已成功关联的候选或当前头像。临时图片仅供上传者预览,不因已取得引用而允许公众读取。
|
||||||
|
|
||||||
|
#### Scenario: 上传失败时提交
|
||||||
|
- **WHEN** 所有者选择了新头像,但图片尚未上传成功
|
||||||
|
- **THEN** 页面明确显示上传状态或失败原因,并阻止提交,允许重试
|
||||||
|
- **AND** 服务端拒绝不存在、未就绪或无权使用的临时引用
|
||||||
|
|
||||||
|
#### Scenario: 新技能同时提交头像
|
||||||
|
- **WHEN** 所有者上传成功并提交有效技能包和头像引用
|
||||||
|
- **THEN** 技能提交与头像关联共同成功,头像进入其所需审核流程
|
||||||
|
- **AND** 页面显示同一审核申请,内容与头像均在统一批准前保持原发布状态
|
||||||
|
|
||||||
|
#### Scenario: 取消编辑或提交失败
|
||||||
|
- **WHEN** 图片已上传,但所有者取消编辑或技能提交失败
|
||||||
|
- **THEN** 当前头像与现有待审申请不变,未关联临时图片可由清理任务回收
|
||||||
|
|
||||||
|
#### Scenario: 使用他人的上传引用
|
||||||
|
- **WHEN** 提交者尝试关联他人的临时图片引用
|
||||||
|
- **THEN** 服务端拒绝提交,不更改技能或头像状态
|
||||||
|
|
||||||
|
#### Scenario: 单独修改头像
|
||||||
|
- **WHEN** 所有者从“我的技能”的“更新”入口上传并提交新头像
|
||||||
|
- **THEN** 复用相同上传、关联及审核流程,不创建新的 SkillVersion
|
||||||
|
|
||||||
|
### Requirement: REQ-AV-11 更新页移除 SHALL 在提交成功后生效
|
||||||
|
|
||||||
|
更新页的移除操作 SHALL 先提供首字母兜底预览。单独移除在提交成功后立即执行;与技能包同次提交则在同一技能审核批准后执行,驳回不改变原头像。取消编辑或提交失败时,原头像及待审申请不变。执行移除无需头像审核,立即恢复兜底,并取消尚未完成的头像申请;旧机器结果或审核动作不得恢复已移除头像。图片私有保留与申请历史沿用 REQ-AV-08。
|
||||||
|
|
||||||
|
#### Scenario: 成功提交移除
|
||||||
|
- **WHEN** 所有者只选择移除头像并成功提交更新
|
||||||
|
- **THEN** Skill 立即恢复首字母兜底,现有待审头像申请失效且历史保留
|
||||||
|
- **WHEN** 所有者将移除与新技能包同次提交
|
||||||
|
- **THEN** 审核批准后共同生效,驳回保留旧头像
|
||||||
|
|
||||||
|
#### Scenario: 移除后旧结果到达
|
||||||
|
- **WHEN** 移除已成功执行,而旧申请的机器结果或人工审核动作随后到达
|
||||||
|
- **THEN** 已移除头像不被恢复
|
||||||
|
|
||||||
|
#### Scenario: 放弃移除或提交失败
|
||||||
|
- **WHEN** 所有者选择移除,但取消编辑或提交失败
|
||||||
|
- **THEN** 原头像及待审申请保持原状
|
||||||
|
|
||||||
|
### Requirement: REQ-AV-12 人工审核 SHALL 复用统一技能申请
|
||||||
|
|
||||||
|
技能包与头像替换/移除同次提交 SHALL 只生成一张现有技能审核申请。批准原子发布本次版本和头像操作,驳回两者均不发布;绑定任务的候选不得因人工开关关闭或机器通过而提前生效。机器未完成或异常禁止批准,机器阻止仍按既有人工改判/例外申诉政策处理并留理由。
|
||||||
|
|
||||||
|
单独修改头像 SHALL 不新增 SkillVersion;需要人工决定时使用元数据审核任务进入原技能审核队列、详情和批准/驳回入口,标明“头像修改”。审核可见性、平台跨团队权限及自审规则沿用 ReviewPermissionChecker,不引入第二套头像人工权限。无须人工审核的单改头像仍按现有策略执行。
|
||||||
|
|
||||||
|
#### Scenario: 同次提交包和头像
|
||||||
|
- **WHEN** 所有者提交有效新版本和头像
|
||||||
|
- **THEN** 只有一张审核申请,批准共同生效,驳回保留原版本发布指针及头像
|
||||||
|
|
||||||
|
#### Scenario: 单改头像
|
||||||
|
- **WHEN** 所有者只提交头像且策略需要人工决定
|
||||||
|
- **THEN** 统一技能队列显示头像修改申请,版本数量不变
|
||||||
|
|
||||||
|
#### Scenario: 候选被替换后批准旧任务
|
||||||
|
- **WHEN** 关联头像已被新申请替换或移除,旧任务随后被批准
|
||||||
|
- **THEN** 整条批准失败,既不发布旧内容也不激活旧头像,失效候选历史仍保留
|
||||||
|
|
||||||
|
#### Scenario: 平台审核团队头像
|
||||||
|
- **WHEN** 平台技能管理员审核团队的统一申请
|
||||||
|
- **THEN** 与团队技能审核使用同一权限,不要求其成为团队成员
|
||||||
|
|
||||||
|
#### Scenario: 相同图片的新申请或策略变化
|
||||||
|
- **WHEN** 已有相同图片的机器通过/阻止记录,所有者提交新申请或审核策略已改变
|
||||||
|
- **THEN** 新申请重新调用配置的供应商,不命中跨候选缓存
|
||||||
|
- **AND** 每次调用及结果记录实际服务/API版本和策略revision,未知模型版本不虚假回填
|
||||||
|
|
||||||
|
#### Scenario: 跨浏览器提交后重新进入审核列表
|
||||||
|
- **WHEN** 审核员曾看到空列表,其他登录会话随后提交新申请,审核员重新进入列表
|
||||||
|
- **THEN** 重新获取当前申请,不能仅以此前缓存的空列表显示“暂无审核记录”
|
||||||
|
- **AND** 命名空间、状态、分页和排序仍各自隔离,不新增轮询或扩大权限
|
||||||
|
|
||||||
|
### Requirement: 提交者 SHALL 可查看已提交头像
|
||||||
|
|
||||||
|
技能所有者在我的技能及更新页 SHALL 可查看已提交头像的紧凑缩略图及当前审核状态。待审候选与当前公开头像须明确区分;通过前公开展示保持旧头像。候选图片只使用现有所有者受保护接口,不公开未审核内容;无法加载候选时显示明确提示,不冒充现用头像。
|
||||||
|
|
||||||
|
#### Scenario: 头像申请等待审核
|
||||||
|
- **WHEN** 所有者提交新头像并返回我的技能或重开更新页
|
||||||
|
- **THEN** 可看到所提交的新头像及审核状态,而公共列表和详情继续展示旧头像
|
||||||
83
openspec/changes/add-skill-avatars-and-moderation/tasks.md
Normal file
83
openspec/changes/add-skill-avatars-and-moderation/tasks.md
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
## 1. 领域与存储
|
||||||
|
|
||||||
|
- [x] 1.1 增加 Skill 头像指针、候选/审计持久化和向后兼容迁移。
|
||||||
|
- [x] 1.2 实现图片解码、大小及像素上限、规范化、私有写入、受控读取与 30 天清理。
|
||||||
|
- [x] 1.3 实现所有者提交/替换/删除、管理员审核/复核/撤下和旧候选失效保护。
|
||||||
|
|
||||||
|
## 2. 审核配置与供应商
|
||||||
|
|
||||||
|
- [x] 2.1 在 `system_setting` 增加独立头像审核开关,验证四种策略及未配置供应商时拒绝开启。
|
||||||
|
- [x] 2.2 依据官方接口实现讯飞和阿里云适配器,部署只选择一家,凭证仅使用部署 Secret。
|
||||||
|
- [x] 2.3 处理超时、未知结果、重试、人工改判和例外申诉。
|
||||||
|
|
||||||
|
## 3. API 与 Web
|
||||||
|
|
||||||
|
- [x] 3.1 扩展 Skill 摘要/详情和头像操作/审核接口,重新生成 OpenAPI 类型。
|
||||||
|
- [x] 3.2 实现统一头像兜底、所有者裁剪/提交/状态及审核员队列。
|
||||||
|
- [x] 3.3 更新中文、英文、俄文文案和操作说明。
|
||||||
|
|
||||||
|
## 4. 验证与交付
|
||||||
|
|
||||||
|
- [x] 4.1 覆盖权限、文件安全、状态并发、策略矩阵、可见范围变化和清理边界。
|
||||||
|
- [x] 4.2 运行相关后端、前端、API 类型检查、容器化本地预览及浏览器操作。
|
||||||
|
- [x] 4.3 独立 Review、Draft PR、CI 与验收报告。
|
||||||
|
|
||||||
|
验证结果以 PR #947 的检查和交付验收记录为准。本节原验收时未发起真实供应商调用;2026-10-14 的讯飞增量联调见下节,阿里云仍未真实调用。
|
||||||
|
|
||||||
|
|
||||||
|
## 5. 发布交互调整(2026-10-13,已实施并验证)
|
||||||
|
|
||||||
|
本节已完成实现、独立 Review、源码检查与真实浏览器验证。新增/更新页使用临时私有上传,“我的技能”保留独立维护,详情页只显示头像与审核状态。原验收保留为历史基线,本节对应流程已重新验证。
|
||||||
|
|
||||||
|
- [x] 5.1 实现所有者临时私有上传、预览、引用校验及未关联文件清理,覆盖越权引用与关联/清理并发。
|
||||||
|
- [x] 5.2 扩展技能提交以处理保留、关联新头像和移除;保证技能提交与头像操作共同成功、失败不改变旧状态、重复提交不重复申请。
|
||||||
|
- [x] 5.3 在新增/更新页接入统一自定义上传裁剪组件,上传未成功禁止提交,默认保留旧头像,支持移除预览。
|
||||||
|
- [x] 5.4 在“我的技能”增加无需新版本的头像维护入口;详情页改为展示及审核状态,技能与头像状态分别呈现,更新三种语言文案。
|
||||||
|
- [x] 5.5 覆盖新增、更新保留/替换/移除、取消、上传失败、提交失败、单独维护、旧审核失效、临时清理及原审核策略回归。
|
||||||
|
- [x] 5.6 更新相关用户文档、OpenAPI 类型及独立 Review,构建当前 SHA 的预览并进行真实浏览器验收,更新同一个 PR #947 的验收记录。
|
||||||
|
|
||||||
|
真实讯飞/阿里云接入是独立的部署联调项,需要已开通能力及部署凭证;模拟测试不得标记为真实调用通过。
|
||||||
|
|
||||||
|
本轮浏览器验收使用 Chromium 145 与 Playwright 1.58.2,分别通过新增/更新/独立维护头像生命周期及无头像正常发布与下载流程;前端 958 项测试、类型检查、Lint、生产构建、真实 PostgreSQL 临时上传与原子提交回归通过。CI 状态及完整验收证据随 PR #947 更新,未自动合并。
|
||||||
|
|
||||||
|
## 6. 复用开源头像裁剪组件(2026-10-13,已实施并验证)
|
||||||
|
|
||||||
|
前述验收对应原居中裁剪实现;本节完成后重新验证受影响的交互及预览,不把旧结果视为新组件验证。
|
||||||
|
|
||||||
|
- [x] 6.1 复用 react-dropzone 文件选择,并用 react-image-crop 替换自写裁剪交互,保留既有私有上传与头像提交状态。
|
||||||
|
- [x] 6.2 验证可移动方形选区、键盘、实际导出位置、文件错误、取消/迟到响应及上传失败重试,完成独立复审。
|
||||||
|
- [x] 6.3 构建当前提交的唯一预览,实际验证新增/更新/独立维护并更新 PR #947 验收记录。
|
||||||
|
|
||||||
|
开源组件替换的验收:真实组件专项14项、全量前端965项及类型/Lint/构建通过;独立Review无阻塞。Chromium实际拖动红蓝测试图到左半,上传512 PNG三个采样点均为红色;中俄键盘操作断言选区位置改变;头像生命周期和无头像发布/下载两个轨道通过。机器供应商真实调用未执行,唯一预览继续保留供人工验收。
|
||||||
|
|
||||||
|
## 7. 人工验收后的入口与展示优化(2026-10-13,已实现并本地验证)
|
||||||
|
|
||||||
|
- [x] 7.1 简化详情展示,补齐我的技能列表头像,合并为唯一更新入口,保留仅改头像不新增版本能力。
|
||||||
|
- [x] 7.2 复用 Radix 弹窗承载开源裁剪组件,父表单紧凑展示,取消恢复草稿并隔离迟到结果。
|
||||||
|
- [x] 7.3 验证更新页头像单独提交/带技能包提交、审核恢复、列表/详情、桌面/移动弹窗和键盘焦点,独立Review后更新同一预览供人工验收。
|
||||||
|
|
||||||
|
本轮本地验收:984项前端测试及类型/Lint/构建通过,独立Review无阻塞;正常发布/下载与统一更新/弹窗两个真实浏览器轨通过。375px视口弹窗343px、图像309px,取消/三语/焦点/两条删除后缓存均通过,最终未知console/page错误、404及requestfailed为0。首轮四项问题已集中修复,一次网络变更失败保留后同源码确认成功;新预览保持唯一环境,待用户人工验收,未合并。
|
||||||
|
|
||||||
|
## 8. 统一技能审核(2026-10-13,已确认,实施中)
|
||||||
|
|
||||||
|
以上已完成项为历史验收,不代表本节完成。
|
||||||
|
|
||||||
|
- [x] 8.1 统一 ReviewTask 的包/头像关联及头像元数据任务,保留旧审核兼容,不新增伪版本。
|
||||||
|
- [x] 8.2 统一批准/驳回、权限、机器门禁、可见范围扩大及旧候选并发防护,保留审计。
|
||||||
|
- [x] 8.3 删除独立头像审核入口,将候选展示和动作整合进原技能审核队列/详情。
|
||||||
|
- [x] 8.4 更新 API 契约及类型,完成针对性回归、独立 Review 与唯一预览的真实浏览器验收。
|
||||||
|
|
||||||
|
- [x] 8.5 取消缺少可信版本依据的跨候选机器缓存,补策略/服务/API版本及每次尝试审计,覆盖策略改变不复用回归。
|
||||||
|
- [x] 8.6 协调PR #935与本分支迁移编号/合并顺序,验证迁移并明确原Issue未交付范围。
|
||||||
|
|
||||||
|
8.6 验证:#935 已于 2026-10-14 合入主线,本分支已同步包含 V69 的 main。真实 PostgreSQL 验证 V68 → V69 → V74 顺序升级(默认 outOfOrder=false),原有全局命名空间记录保持;标准 Compose 组合启动、登录及真实浏览器业务验收通过。最终 CI 与合并门禁由 PR 状态记录。原 Issue 的本地 Scanner 模型评测及模型供应链范围未交付,不将两家云审核接入视为该范围完成。
|
||||||
|
|
||||||
|
## 9. 供应商增量验证与文档(2026-10-14)
|
||||||
|
|
||||||
|
- [x] 9.1 真实讯飞接口验证正常图片返回 PASS、测试图片返回 BLOCK;确认审计结果生效、候选保持私有。
|
||||||
|
- [x] 9.2 浏览器验证 BLOCK 申请仍待人工审核、无理由批准禁用、有理由可人工复核;本次只执行人工拒绝,用户可见原因,公开指针未切到违规候选。
|
||||||
|
- [x] 9.3 独立检查阿里云官方临时上传/同步审核契约、风险映射、失败处理及供应商切换,供应商定向测试 4 项通过;补充用户与部署文档。
|
||||||
|
- [ ] 9.4 阿里云真实账号联调:用户决定本期暂不执行,不能将模拟响应测试标为真实接入通过。
|
||||||
|
- [x] 9.5 新增文档站「技能头像与图片审核接入」指南及侧栏/发布/审核入口,覆盖凭证、标准 Compose 接入、开关及排障;两家 JSON 与 Compose 注入示例纯解析、文档站构建和独立复核通过,未发起真实云调用。
|
||||||
|
|
||||||
|
本节不替代完整 PR 门禁,不表示已合并。未实际发布被机器阻止后人工改判通过的图片。唯一预览继续保留供用户验收。
|
||||||
57
openspec/changes/configure-auth-and-initial-roles/design.md
Normal file
57
openspec/changes/configure-auth-and-initial-roles/design.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
## Context
|
||||||
|
|
||||||
|
当前 `OAuthLoginFlowService` 每次外部登录都先调用 `AccessPolicy.evaluate`,只有 `ALLOW` 才进入 `IdentityBindingService.bindOrCreate`。主线新增的统一身份核心在公开 OAuth 路径仍保留旧 `identity_binding` 写入权威;`LegacyPlatformIdentityCoreBridge` 不执行按已验证邮箱关联旧账号,不能把本需求实现成邮箱自动合并。现有四种可配置准入策略实际只返回 `ALLOW` 或 `DENY`;`PENDING_APPROVAL` 是预留分支。外部身份以 `(provider_code, subject)` 绑定用户,已验证邮箱才可作为可信邮箱。平台已有 `SUPER_ADMIN`、`SKILL_ADMIN`、`USER_ADMIN`、`AUDITOR` 角色;后台用户管理一次设置一个平台角色。现有 `BootstrapAdminInitializer` 会在启用时于每次启动检查并补建本地密码超管,它不是本方案的一次性外部角色规则。
|
||||||
|
|
||||||
|
## 术语
|
||||||
|
|
||||||
|
- **普通准入**:外部身份能否进入 SkillHub 的现有策略判断,与平台角色授予分开。
|
||||||
|
- **首次角色授权规则**:部署方为指定外部来源和已验证邮箱预设的单次平台角色授予。它只作用于首次创建的 SkillHub 账号。
|
||||||
|
- **已消费规则**:已绑定一个 `(provider_code, subject)` 和 SkillHub 用户并完成角色写入的规则。停用或删除未消费规则不撤销历史角色。
|
||||||
|
- **运行设置**:由数据库管理、后台可修改的非秘密系统行为设置;不同于 OAuth 密钥等部署秘密。
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. 统一页面、分表持久化
|
||||||
|
|
||||||
|
“系统配置”页面分为“本地认证”和“外部账号首次授权”两部分。`system_setting` 保存少量经过代码注册、定型和校验的运行设置,不提供任意键值编辑。首批设置键 `auth.local` 的 JSON 对象包含 `passwordLoginEnabled` 和 `selfRegistrationEnabled`,两者默认均为 `true`。同一行更新这组设置,并使用版本号进行条件更新,避免并发管理操作互相覆盖。
|
||||||
|
|
||||||
|
页面内两个开关先形成未保存草稿,由管理员统一保存或放弃。关闭密码登录时,在站内确认框中展示管理员可能无法重新登录的后果,确认后才提交;取消确认保留草稿,不修改服务端。首次授权规则的角色变更也需显式保存,停用规则使用站内确认框。失败时保留可见的错误提示和原编辑内容。
|
||||||
|
|
||||||
|
建议表结构:`id BIGSERIAL PRIMARY KEY`、`setting_key VARCHAR(128) UNIQUE NOT NULL`、`value_json JSONB NOT NULL`、`version BIGINT NOT NULL`、`created_at/updated_at TIMESTAMP NOT NULL`、`updated_by VARCHAR(128) NULL`。数据库约束验证 JSON 为对象;应用按设置键验证完整字段、类型和允许值。`id` 可用作现有 `audit_log.target_id`。审计记录操作者、旧值、新值和时间;不得把秘密配置写入设置或审计详情。
|
||||||
|
|
||||||
|
认证路径直接读取数据库中已持久化的安全开关。首版不使用单节点内存缓存,以免多副本部署时设置失效不一致;读取失败应返回服务错误,不能以默认“开启”放行。前端只读取可公开的认证能力投影,不暴露通用设置读写接口。
|
||||||
|
|
||||||
|
### 2. 两个本地开关保持独立
|
||||||
|
|
||||||
|
关闭密码登录后,所有发起新本地密码认证的路径均拒绝,包括现有 direct 本地认证路径;密码重置和密码修改操作也不再提供。由于当前本地注册成功会立即建立会话,即使自行注册开关的存储值仍为开启,本地注册也必须暂时不可用;重新开启密码登录后恢复该开关原有值。登录页不展示本地密码表单、注册和重置密码入口,个人设置不展示密码修改入口,直接访问相关页面得到明确的不可用提示。已有会话不批量失效,按现有会话生命周期结束。
|
||||||
|
|
||||||
|
关闭自行注册后,仅本地注册 API 和页面不可用。已有本地账号仍可密码登录、重置密码和修改密码;外部身份首次进入仍遵守现有普通准入策略,不受此开关影响。
|
||||||
|
|
||||||
|
超级管理员关闭密码登录时,页面明确提示若外部登录不可用可能失去管理入口;服务端不强制阻止保存。外部身份提供方实际连通性无法由“已配置”推断,部署方负责核验。紧急恢复使用受控数据库操作。
|
||||||
|
|
||||||
|
### 3. 普通准入优先,首次创建才授权
|
||||||
|
|
||||||
|
外部登录顺序为:验证外部身份 → 执行现有普通准入 → 若为 `ALLOW`,按 `(provider_code, subject)` 查找或创建 SkillHub 账号 → **仅新建 ACTIVE 账号**匹配首次角色规则 → 在同一事务中写入角色、规则消费状态和身份绑定 → 生成包含新角色的登录主体及会话。`DENY` 不创建账号、不消费规则、不授予角色。已存在账号即使后来新增规则,也不在下次登录时自动授权;使用现有用户管理页面人工修改角色。后续每次外部登录仍按现有逻辑重新检查普通准入。
|
||||||
|
|
||||||
|
首版规则以 `provider_code` 和规范化的**已验证邮箱**匹配。未返回邮箱、邮箱未验证、来源不匹配或账号已存在都不授予角色。匹配成功后记录实际 `(provider_code, subject)` 和用户 ID;规则不能因邮箱回收而再次授予另一身份。后台为每条规则选择一个现有平台角色,不创建自定义角色或命名空间角色,也不把角色写死为 `SUPER_ADMIN`。规则消费后修改或停用不改动已写入 `user_role_binding` 的角色。
|
||||||
|
|
||||||
|
当前飞书和钉钉适配器没有可证明邮箱已验证的资料,明确返回 `emailVerified=false`,因此这两个来源的邮箱规则首版不会被消费。不能为实现首次授权而将它们标记为已验证。部署方须使用已验证邮箱来源,或先保留其他超级管理员入口并在首次登录后人工授权;飞书、钉钉的可信身份依据需要单独设计。
|
||||||
|
|
||||||
|
现有公开 OAuth 账号不按邮箱自动合并:即使本地账号已经使用相同邮箱,首次进入的外部身份仍会创建独立账号;若命中规则,角色授予这个新外部账号,不授予本地账号。规则配置页须明确提示“同邮箱不代表同一 SkillHub 账号”,并在消费结果中展示实际获授角色的账号与外部来源,防止管理员误认授权对象。角色授予接入当前公开 OAuth 的账号创建事务,不改变统一身份核心已有的关联或准入决策。
|
||||||
|
|
||||||
|
建议独立表保存 `id`、`provider_code`、`normalized_email`、`role_id`、`status`(`ACTIVE`/`DISABLED`/`CONSUMED`)、`matched_subject`、`granted_user_id`、`granted_at`、`created_by`、`updated_by`、时间戳和并发版本。最多允许一条同一来源与规范化邮箱的 ACTIVE 规则;消费记录保留用于审计。首次绑定、角色写入和规则消费必须原子完成,并通过身份唯一约束及规则条件更新处理并发首次登录。若规则在登录时被停用,以事务中读取并确认的当前状态为准。
|
||||||
|
|
||||||
|
当前 `PENDING_APPROVAL` 没有实际策略来源。本变更不定义待审批账号的延迟自动授予;未来启用该策略时,需要单独确定审批后的规则消费时点,不能把 PENDING 账号当作已获角色的成功登录。
|
||||||
|
|
||||||
|
### 4. 部署参数只初始化一次
|
||||||
|
|
||||||
|
首次部署时,部署参数为缺失的 `auth.local` 设置行提供初始值,并可为新安装提供初始外部角色规则。初始化结果及“已初始化”状态必须持久化。此后即使规则表被清空或某设置被后台修改,重启也不能重新灌入旧部署值。新版本新增的设置键可独立补入默认值,但不得覆盖旧键。初始化角色规则必须验证来源、邮箱格式、目标角色与重复项;错误不能静默退化为超管授权。
|
||||||
|
|
||||||
|
`BOOTSTRAP_ADMIN_*` 当前用于本地密码管理员,仍按现有独立机制处理;本变更不把它迁入数据库,也不改变其启动行为。OAuth 客户端密钥、数据库凭证等继续放部署秘密配置。新安装若同时关闭本地登录,部署方必须确保外部来源、普通准入策略和初始角色规则相互匹配;规则不会绕过准入。
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **管理员规则绕过普通准入**:拒绝。会改变现有每次登录先检查准入的行为,并引入长期准入例外。
|
||||||
|
- **每次登录按规则同步角色**:拒绝。会覆盖或重新授予人工调整过的权限,也与“停用只阻止新授权”冲突。
|
||||||
|
- **所有数据放 `system_setting` JSON**:拒绝。逐人规则需要独立生命周期、唯一性、并发消费和审计。
|
||||||
|
- **关闭本地登录时强制阻止可能锁定的配置**:拒绝。外部提供方是否实际可用无法可靠静态判断;页面明确提示后允许部署方决定。
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Issue #923 需要让采用外部身份认证的部署方分别关闭本地密码登录和本地自行注册。现有本地入口始终可见,后端也没有对应开关;仅依赖部署参数无法让管理员在后台维护运行设置。纯外部登录部署还需要一种明确指定外部账号首次进入 SkillHub 时所获平台角色的方式,同时保留现有 OAuth 准入优先级。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 增加统一的后台“系统配置”页面。两个本地认证开关存入 `system_setting`;外部账号首次角色授权规则存入独立表。只有超级管理员可修改,操作进入现有审计日志。
|
||||||
|
- 本地密码登录和本地自行注册分别控制,默认保持现状。关闭登录时,前后端停止新的密码认证和密码管理操作;已建立的会话按原有效期处理。关闭注册只停止本地自行注册,不影响已有账号的密码登录或外部身份首次进入。
|
||||||
|
- 外部身份仍先经过现有普通准入策略。仅当准入允许且外部身份首次创建 ACTIVE 账号时,匹配来源与已验证邮箱,授予规则指定的一个平台角色。规则不覆盖准入拒绝,也不在已有账号下次登录时补授权。
|
||||||
|
- 部署参数仅用于首次初始化缺失的运行设置和空库的初始外部角色规则;初始化状态持久化。之后数据库和后台页面是权威来源,删除规则后重启不得复活。紧急恢复使用受控数据库操作,不增加专用服务器命令。
|
||||||
|
- 关闭本地登录时展示管理入口锁定风险,但允许超级管理员继续保存。外部身份提供方凭证等秘密继续由部署配置管理,不进入 `system_setting`。
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `system-auth-settings`:数据库持久化的本地认证开关、后台配置、公开能力展示和一次性初始化。
|
||||||
|
- `initial-external-role-grants`:外部身份首次创建账号时的一次性平台角色授予规则。
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- 后端认证、OAuth 首次账号创建、数据库迁移、审计和后台管理 API。
|
||||||
|
- 登录、注册、重置密码、个人设置和后台系统配置页面。
|
||||||
|
- 部署参数仅增加初始化输入;现有准入策略、已建会话和人工用户角色管理语义保持不变。
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
已按本变更实现功能,并完成相关单测、PostgreSQL 并发测试和本地 Compose/浏览器验收。真实第三方 OAuth 登录未在本地预览中接入,准入与角色授予由服务测试覆盖。
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
允许部署方为外部身份首次创建的 SkillHub 账号预设一个平台角色,同时保留普通准入和人工角色管理的现有行为。
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: REQ-IERG-01 外部角色规则 SHALL 只在准入允许后的首次账号创建时生效
|
||||||
|
|
||||||
|
系统 SHALL 先执行现有普通准入判断。只有 `ALLOW` 且 `(provider_code, subject)` 尚无 SkillHub 账号时,才可匹配 ACTIVE 规则。规则 SHALL 要求来源和规范化的已验证邮箱一致,并授予所选的一个现有平台角色。授权写入 SHALL 在登录主体和会话建立前完成。
|
||||||
|
|
||||||
|
#### Scenario: 新账号匹配规则
|
||||||
|
- **WHEN** 外部身份通过普通准入,来源和已验证邮箱命中 ACTIVE 规则,且身份首次创建账号
|
||||||
|
- **THEN** 系统创建 ACTIVE 账号、身份绑定和角色绑定,并消费规则
|
||||||
|
- **AND** 首次登录主体包含该平台角色
|
||||||
|
|
||||||
|
#### Scenario: 普通准入拒绝
|
||||||
|
- **WHEN** 外部身份虽命中管理员规则,但普通准入结果为 `DENY`
|
||||||
|
- **THEN** 系统拒绝登录,不创建账号、不消费规则、不授予角色
|
||||||
|
|
||||||
|
#### Scenario: 邮箱不可信
|
||||||
|
- **WHEN** 外部来源未提供邮箱,或邮箱未验证
|
||||||
|
- **THEN** 系统不通过邮箱规则自动授予角色
|
||||||
|
|
||||||
|
#### Scenario: 来源不提供邮箱验证信号
|
||||||
|
- **WHEN** 管理员尝试为当前始终返回未验证邮箱的飞书或钉钉来源配置首次角色规则
|
||||||
|
- **THEN** 系统拒绝创建无法生效的规则,并说明该来源不支持已验证邮箱匹配
|
||||||
|
|
||||||
|
#### Scenario: 已有普通账号后来出现规则
|
||||||
|
- **WHEN** 已有身份绑定的普通账号再次登录,后台才新增对应规则
|
||||||
|
- **THEN** 登录不消费该规则,也不自动改变已有角色
|
||||||
|
- **AND** 管理员可通过现有用户管理功能人工授权
|
||||||
|
|
||||||
|
#### Scenario: 同邮箱的本地账号与外部账号
|
||||||
|
- **WHEN** 本地账号已使用某邮箱,另一外部身份首次进入且以同一已验证邮箱命中规则
|
||||||
|
- **THEN** 系统按现有身份绑定逻辑创建独立的外部账号,并把角色授予该新账号
|
||||||
|
- **AND** 不把角色授予同邮箱的本地账号,也不自动合并两个账号
|
||||||
|
|
||||||
|
### Requirement: REQ-IERG-02 规则消费和角色授予 SHALL 原子且一次性
|
||||||
|
|
||||||
|
系统 SHALL 在同一数据库事务内完成首次身份绑定、角色写入和规则消费。消费记录 SHALL 绑定实际来源、subject 和用户,防止邮箱再次归属另一人后重复授权。并发首次登录 SHALL 最多授予同一用户一次,不得把规则授予两个账号。
|
||||||
|
|
||||||
|
#### Scenario: 并发首次登录
|
||||||
|
- **WHEN** 同一外部身份几乎同时发起两次首次登录
|
||||||
|
- **THEN** 最终只有一个账号和身份绑定,规则只消费一次,角色结果一致
|
||||||
|
|
||||||
|
#### Scenario: 规则授予后停用
|
||||||
|
- **WHEN** 规则已经授予角色,管理员随后停用或删除该规则
|
||||||
|
- **THEN** 已有角色保持,已有账号以后登录仍按普通准入策略判断
|
||||||
|
- **AND** 规则不能再次授予另一外部身份
|
||||||
|
|
||||||
|
#### Scenario: 人工修改角色
|
||||||
|
- **WHEN** 管理员在用户管理页面修改已获角色的账号
|
||||||
|
- **THEN** 后续登录不因旧规则重新授予或覆盖角色
|
||||||
|
|
||||||
|
### Requirement: REQ-IERG-03 规则管理 SHALL 限定超管并保留审计
|
||||||
|
|
||||||
|
系统 SHALL 在统一“系统配置”页面提供规则增改、停用和消费结果查看。只允许超级管理员管理规则。角色只能从已存在的平台角色中选择,每条规则只指定一个角色。规则变更和自动授予 SHALL 记录审计,不得泄漏 OAuth 凭证。
|
||||||
|
|
||||||
|
规则页面 SHALL 告知管理员“同邮箱不代表同一 SkillHub 账号”,消费结果 SHALL 展示实际获授角色的账号和外部来源。
|
||||||
|
|
||||||
|
#### Scenario: 非超管试图配置超级管理员规则
|
||||||
|
- **WHEN** 非超级管理员直接调用规则写入 API
|
||||||
|
- **THEN** 系统拒绝请求,且规则和角色绑定均不改变
|
||||||
|
|
||||||
|
#### Scenario: 重复有效规则
|
||||||
|
- **WHEN** 同一外部来源和规范化邮箱已有 ACTIVE 规则
|
||||||
|
- **THEN** 系统拒绝第二条同时生效的规则
|
||||||
|
|
||||||
|
### Requirement: REQ-IERG-04 部署初始化 SHALL 一次性且不复活已删除规则
|
||||||
|
|
||||||
|
新安装可用部署参数提供初始角色规则,但系统 SHALL 持久化初始化状态。后续由数据库和后台页面管理;表为空不构成再次初始化条件。紧急恢复可由受控数据库操作完成。
|
||||||
|
|
||||||
|
#### Scenario: 管理员删除全部规则后重启
|
||||||
|
- **WHEN** 初始化已完成,管理员删除或停用所有规则,而旧部署参数仍存在
|
||||||
|
- **THEN** 重启不重新创建这些规则
|
||||||
|
|
||||||
|
#### Scenario: 初始规则不符合普通准入
|
||||||
|
- **WHEN** 初始管理员规则指向的身份被现有普通准入策略拒绝
|
||||||
|
- **THEN** 规则不绕过准入,也不完成授权
|
||||||
|
- **AND** 部署方需调整普通准入或规则配置
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
让部署方分别控制本地密码登录和自行注册,并在统一后台页面管理非秘密运行设置。
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: REQ-SAS-01 两个本地认证开关 SHALL 独立且默认保持现状
|
||||||
|
|
||||||
|
系统 SHALL 提供密码登录与本地自行注册两个独立布尔开关。未配置时两者 SHALL 默认为开启。关闭任一开关 SHALL 同时作用于前端入口和对应后端请求。
|
||||||
|
|
||||||
|
#### Scenario: 只关闭自行注册
|
||||||
|
- **WHEN** `selfRegistrationEnabled=false` 且 `passwordLoginEnabled=true`
|
||||||
|
- **THEN** 本地注册页面和 API 不可用
|
||||||
|
- **AND** 现有本地账号仍可登录和使用密码管理功能
|
||||||
|
- **AND** 外部身份首次进入仍按现有准入策略处理
|
||||||
|
|
||||||
|
#### Scenario: 只关闭密码登录
|
||||||
|
- **WHEN** `passwordLoginEnabled=false` 且 `selfRegistrationEnabled=true`
|
||||||
|
- **THEN** 密码登录、direct 本地认证、密码重置和密码修改均不可用
|
||||||
|
- **AND** 本地注册也暂时不可用,因为现有注册成功会直接建立会话
|
||||||
|
- **AND** 页面不展示上述入口,直接访问页面得到明确提示
|
||||||
|
- **AND** 注册开关仍保留自身设置值,重新开启密码登录后恢复其原有作用
|
||||||
|
|
||||||
|
#### Scenario: 已有会话
|
||||||
|
- **WHEN** 管理员关闭密码登录,而某本地账号已经建立会话
|
||||||
|
- **THEN** 该会话按现有有效期和失效机制处理
|
||||||
|
- **AND** 后续新密码认证被拒绝
|
||||||
|
|
||||||
|
### Requirement: REQ-SAS-02 系统设置 SHALL 由数据库管理并受权限及审计保护
|
||||||
|
|
||||||
|
系统 SHALL 将注册过的非秘密运行设置持久化在 `system_setting`,只允许超级管理员通过后台修改,使用版本条件防止并发覆盖,并记录现有审计日志。公开认证能力接口 SHALL 只暴露登录页所需的安全字段。
|
||||||
|
|
||||||
|
#### Scenario: 并发修改
|
||||||
|
- **WHEN** 两名管理员基于同一旧版本分别提交修改
|
||||||
|
- **THEN** 只有先成功提交的修改生效,另一请求收到可识别的版本冲突
|
||||||
|
|
||||||
|
#### Scenario: 读取设置失败
|
||||||
|
- **WHEN** 新认证请求无法读取数据库中的本地认证设置
|
||||||
|
- **THEN** 请求失败并给出服务不可用结果
|
||||||
|
- **AND** 不使用默认开启值放行认证
|
||||||
|
|
||||||
|
#### Scenario: 非超级管理员修改
|
||||||
|
- **WHEN** 普通用户或非超管管理员直接调用设置写入 API
|
||||||
|
- **THEN** 系统拒绝请求且不修改设置
|
||||||
|
|
||||||
|
### Requirement: REQ-SAS-03 部署参数 SHALL 只初始化缺失的设置
|
||||||
|
|
||||||
|
新安装首次初始化时 SHALL 使用部署参数或代码默认值填充缺失设置,并持久化初始化状态。已经持久化的设置 SHALL 不被后续部署参数或重启覆盖。外部服务秘密不得存入设置表。
|
||||||
|
|
||||||
|
已有用户的实例升级且首次缺失 `auth.local` 时 SHALL 初始化为两个开关均开启,保持原有认证行为;部署参数不应在该升级场景关闭本地认证。
|
||||||
|
|
||||||
|
#### Scenario: 后台关闭登录后重启
|
||||||
|
- **WHEN** 后台已持久化 `passwordLoginEnabled=false`,部署参数仍写着开启
|
||||||
|
- **THEN** 重启后数据库设置仍为关闭
|
||||||
|
|
||||||
|
#### Scenario: 已有用户的实例首次升级
|
||||||
|
- **WHEN** 数据库已有用户,但首次升级时还没有 `auth.local`,且部署参数设为关闭本地密码登录
|
||||||
|
- **THEN** 系统把两个开关初始化为开启,不因部署参数切断原有登录入口
|
||||||
|
|
||||||
|
#### Scenario: 新版本增加设置键
|
||||||
|
- **WHEN** 升级版本新增注册过的设置键
|
||||||
|
- **THEN** 系统只为新键补入默认值,不修改既有键
|
||||||
|
|
||||||
|
### Requirement: REQ-SAS-04 关闭本地登录 SHALL 展示风险但不阻止保存
|
||||||
|
|
||||||
|
后台页面 SHALL 告知超级管理员:外部登录不可用时可能失去管理入口。服务端 SHALL 接受其关闭本地登录的合法请求,而不根据外部账号是否已绑定进行硬拦截。
|
||||||
|
|
||||||
|
#### Scenario: 没有已绑定的外部超管
|
||||||
|
- **WHEN** 超级管理员确认关闭本地密码登录
|
||||||
|
- **THEN** 页面展示管理入口风险,保存仍可完成
|
||||||
|
- **AND** 不误称已经验证外部身份提供方可用
|
||||||
25
openspec/changes/configure-auth-and-initial-roles/tasks.md
Normal file
25
openspec/changes/configure-auth-and-initial-roles/tasks.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
## 1. 持久化与初始化
|
||||||
|
|
||||||
|
- [x] 增加 `system_setting` 和外部首次角色规则表、约束与审计目标。
|
||||||
|
- [x] 实现部署参数一次性初始化与持久化标记,验证重启和清空规则后不会重灌(单测)。
|
||||||
|
- [x] 实现设置键注册、类型校验、版本条件更新和数据库读取失败处理。
|
||||||
|
|
||||||
|
## 2. 认证与授权
|
||||||
|
|
||||||
|
- [x] 在本地登录、direct 本地认证、注册和密码管理服务端入口执行对应开关;关闭密码登录时一并阻止会直接建会话的本地注册。
|
||||||
|
- [x] 在 OAuth 普通准入允许后的首次 ACTIVE 账号创建事务中匹配并消费角色规则,首次主体包含新角色。
|
||||||
|
- [x] 在统一身份核心的 `LEGACY`、`SHADOW`、`ACTIVE` 模式下核对公开 OAuth 接入点;保持旧身份绑定写入权威与同邮箱不自动合并的现有行为(`OAuthLoginFlowServiceTest`、`IdentityBindingServiceTest`)。
|
||||||
|
- [x] 保持已有账号、准入拒绝、未验证邮箱、停用规则、并发首次登录和人工角色修改的既定行为(OAuth 和授权单测、PostgreSQL 同身份及同邮箱不同身份并发测试与人工撤权测试)。
|
||||||
|
|
||||||
|
## 3. API 与 Web
|
||||||
|
|
||||||
|
- [x] 增加超级管理员可读写的系统设置与规则 API,增加登录页所需的公开认证能力投影。
|
||||||
|
- [x] 建立统一“系统配置”页面,显示两开关、角色规则、消费结果及关闭本地登录的风险提示。
|
||||||
|
- [x] 调整登录、注册、重置密码和个人设置入口;直接访问已关闭页面时显示明确结果。
|
||||||
|
- [x] 更新 OpenAPI、部署说明和用户操作说明。
|
||||||
|
|
||||||
|
## 4. 验收
|
||||||
|
|
||||||
|
- [x] 验证两个开关的四种组合、直接 API 绕过尝试、已有会话、DB 故障与多实例设置可见性(本地 Compose HTTP 实测;DB 故障与两个服务实例读取使用 `LocalAuthSettingsServiceTest`)。
|
||||||
|
- [x] 验证首次登录授权、准入优先、已有账号不补授权、同邮箱独立账号、邮箱验证、规则停用、角色人工修改和并发首次登录(OAuth/授权单测与 PostgreSQL 真实事务测试;本地 Compose 未接入真实外部 IdP)。
|
||||||
|
- [x] 验证一次性部署初始化、角色规则删除后重启、非超管越权、审计记录和浏览器页面流程(初始化单测、Compose 权限与审计实测、PostgreSQL 自动授权审计断言、Playwright 浏览器操作;常驻 smoke 覆盖公开能力与后台配置读取)。
|
||||||
32
openspec/changes/refine-shadcn-ui-foundation/design.md
Normal file
32
openspec/changes/refine-shadcn-ui-foundation/design.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Keep component source in `web/src/shared/ui` and use explicit shadcn aliases for UI, composition,
|
||||||
|
utilities and hooks. Compare compatible upstream source before adapting individual components;
|
||||||
|
existing APIs, variants, native attributes, refs and event behavior remain the compatibility baseline.
|
||||||
|
|
||||||
|
CSS custom properties own semantic colors, fonts and density. Tailwind consumes these properties.
|
||||||
|
Regular density remains the default; `data-density="compact"` changes control height and surface
|
||||||
|
spacing without shrinking default control text. Explicit caller classes and styles remain effective.
|
||||||
|
Table headings and cells share horizontal padding. Portal content must retain usable independent sizing.
|
||||||
|
|
||||||
|
Preserve self-hosted Inter and JetBrains Mono and platform-appropriate fallback fonts. Provide semantic
|
||||||
|
status colors in both themes, visible focus and invalid styling, and reduced-motion treatment.
|
||||||
|
|
||||||
|
Keep the existing Tabs API and panel mounting behavior. Navigate enabled triggers in their rendered
|
||||||
|
order with arrow keys and Home/End; respect horizontal direction. A valid enabled selection is the
|
||||||
|
tab stop, otherwise the first enabled trigger is. Recomputing a tab stop must not change selection
|
||||||
|
or invoke `onValueChange`. An entirely disabled list has no actionable tab stop. Trigger IDs/aria-controls and panel IDs/aria-labelledby
|
||||||
|
are reserved for the generated association; other supported native attributes and refs remain available.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Use component behavior tests and an isolated browser fixture for keyboard, density and validation
|
||||||
|
contracts. Verify light/dark and regular/compact combinations plus representative page layouts.
|
||||||
|
Keep test fixtures out of production entry points. Actual backend smoke results are reported separately
|
||||||
|
from UI fixtures; successful frontend tests alone do not establish backend integration health.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
Shared font and spacing changes affect many callers. Check existing variants and explicit class/style
|
||||||
|
overrides, table alignment, nested portals and responsive layouts before accepting the change.
|
||||||
|
No dependency migration or global component replacement is required.
|
||||||
26
openspec/changes/refine-shadcn-ui-foundation/proposal.md
Normal file
26
openspec/changes/refine-shadcn-ui-foundation/proposal.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Shared controls need a consistent foundation for future page work. The current shadcn configuration
|
||||||
|
does not explicitly resolve the UI directory, CSS and Tailwind disagree on the sans-serif font,
|
||||||
|
and shared controls do not expose a consistent density or validation treatment. Tabs also need
|
||||||
|
complete basic keyboard navigation while preserving optional controlled and uncontrolled values.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Resolve shadcn aliases to the existing shared directories without replacing component APIs.
|
||||||
|
- Align self-hosted fonts and semantic light/dark tokens, and add regular/compact container density.
|
||||||
|
- Apply shared density, focus and invalid states to existing controls, cards and tables.
|
||||||
|
- Complete Tabs keyboard navigation and retain an entry point when selection is absent or invalid.
|
||||||
|
- Add behavior-focused regression coverage and verify representative existing pages.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `shared-ui-foundation`: shared theme, density, component compatibility and keyboard behavior.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
Changes are limited to frontend configuration, theme, shared UI components and regression tests.
|
||||||
|
React 19, Tailwind 3, existing Radix primitives and current business behavior are retained.
|
||||||
|
This change does not redesign business pages or add a global shortcut system.
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Component generation uses existing shared directories
|
||||||
|
The project SHALL configure shadcn aliases to generate UI primitives into `src/shared/ui` and
|
||||||
|
resolve imports using the existing TypeScript and Vite aliases.
|
||||||
|
|
||||||
|
#### Scenario: Preview a new component
|
||||||
|
- **WHEN** a representative component is previewed with the compatible CLI
|
||||||
|
- **THEN** its UI file resolves into `src/shared/ui`, without a nested `ui/ui` directory
|
||||||
|
- **AND** dependencies and source changes can be reviewed before applying them
|
||||||
|
|
||||||
|
### Requirement: Shared theme and density are consistent
|
||||||
|
The frontend SHALL use consistent self-hosted font families and semantic light/dark colors.
|
||||||
|
Shared controls SHALL support regular and compact container density without reducing default text size.
|
||||||
|
|
||||||
|
#### Scenario: Switch density
|
||||||
|
- **WHEN** a container switches from regular to compact
|
||||||
|
- **THEN** default controls change from 36px to 32px and card/table spacing follows the shared density
|
||||||
|
- **AND** table heading and data text remain horizontally aligned
|
||||||
|
- **AND** caller-provided class and style overrides remain effective
|
||||||
|
|
||||||
|
#### Scenario: Validation and reduced motion
|
||||||
|
- **WHEN** a form control is invalid or receives keyboard focus
|
||||||
|
- **THEN** it has visible invalid or focus treatment appropriate to the selected theme
|
||||||
|
- **WHEN** reduced motion is requested
|
||||||
|
- **THEN** nonessential shared control motion is suppressed
|
||||||
|
|
||||||
|
### Requirement: Existing component contracts remain compatible
|
||||||
|
Shared component changes SHALL retain existing variants, native attributes, refs, events, controlled
|
||||||
|
and uncontrolled values, form semantics, portal behavior and panel mounting behavior.
|
||||||
|
|
||||||
|
#### Scenario: Reuse an existing button variant
|
||||||
|
- **WHEN** a page renders an existing variant such as `soft`
|
||||||
|
- **THEN** the variant remains available and existing caller attributes are honored
|
||||||
|
|
||||||
|
### Requirement: Tabs are keyboard accessible
|
||||||
|
Tabs SHALL support arrow-key navigation, Home/End and disabled-item skipping using the current
|
||||||
|
horizontal direction. At least one enabled trigger SHALL provide a keyboard entry when available.
|
||||||
|
|
||||||
|
#### Scenario: Navigate enabled tabs
|
||||||
|
- **WHEN** the user presses a navigation key on a tab
|
||||||
|
- **THEN** focus and selection follow the intended enabled trigger in rendered order
|
||||||
|
- **AND** disabled triggers are skipped
|
||||||
|
|
||||||
|
#### Scenario: Missing or invalid selection
|
||||||
|
- **WHEN** selection is absent, invalid, removed or disabled and an enabled trigger exists
|
||||||
|
- **THEN** the first enabled trigger remains reachable with Tab
|
||||||
|
- **AND** establishing that entry does not change selection or invoke the selection callback
|
||||||
|
|
||||||
|
#### Scenario: All triggers are disabled
|
||||||
|
- **WHEN** every trigger is disabled
|
||||||
|
- **THEN** no trigger is exposed as an actionable keyboard entry
|
||||||
|
|
||||||
|
### Requirement: Regression evidence is repeatable
|
||||||
|
The change SHALL include repeatable component regression tests and pass relevant frontend checks.
|
||||||
|
Browser fixtures SHALL be separate from production entry points.
|
||||||
|
|
||||||
|
#### Scenario: Validate component behavior independently
|
||||||
|
- **WHEN** the component fixture is tested without a backend
|
||||||
|
- **THEN** keyboard and density assertions run against actual shared components
|
||||||
|
- **AND** the result is described as component verification rather than backend acceptance
|
||||||
14
openspec/changes/refine-shadcn-ui-foundation/tasks.md
Normal file
14
openspec/changes/refine-shadcn-ui-foundation/tasks.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- [x] 1. Inspect existing components, callers, tokens and compatible shadcn configuration.
|
||||||
|
- [x] 2. Correct aliases and verify CLI project information and a representative dry-run.
|
||||||
|
- [x] 3. Align fonts, semantic colors, density and shared validation/focus treatment.
|
||||||
|
- [x] 4. Complete Tabs navigation and missing-selection keyboard entry behavior.
|
||||||
|
- [x] 5. Add repeatable behavior and browser regression tests.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [x] 6. Pass relevant frontend typecheck, lint, unit tests and production build.
|
||||||
|
- [x] 7. Verify light/dark, regular/compact and desktop/mobile component behavior.
|
||||||
|
- [x] 8. Check representative existing pages, overrides and production entry isolation.
|
||||||
|
- [x] 9. Record actual verification results and any unverified integration layers.
|
||||||
|
|
@ -98,6 +98,7 @@ check_health "Health endpoint" "$ACTUATOR_BASE_URL/actuator/health"
|
||||||
check_protected_actuator "Prometheus metrics requires auth" "$ACTUATOR_BASE_URL/actuator/prometheus"
|
check_protected_actuator "Prometheus metrics requires auth" "$ACTUATOR_BASE_URL/actuator/prometheus"
|
||||||
check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401"
|
check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401"
|
||||||
check "Auth required" "$BASE_URL/api/v1/auth/me" "401"
|
check "Auth required" "$BASE_URL/api/v1/auth/me" "401"
|
||||||
|
check "Local auth capabilities" "$BASE_URL/api/v1/auth/local/capabilities" "200"
|
||||||
|
|
||||||
curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null
|
curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null
|
||||||
CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_JAR" | tail -n 1)"
|
CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_JAR" | tail -n 1)"
|
||||||
|
|
@ -227,6 +228,26 @@ fi
|
||||||
# Refresh CSRF after login
|
# Refresh CSRF after login
|
||||||
ADMIN_CSRF="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$ADMIN_COOKIE_JAR" | tail -n 1)"
|
ADMIN_CSRF="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$ADMIN_COOKIE_JAR" | tail -n 1)"
|
||||||
|
|
||||||
|
SYSTEM_CONFIG_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-b "$ADMIN_COOKIE_JAR" "$BASE_URL/api/v1/admin/system-config/auth/local" || true)"
|
||||||
|
if [[ "$SYSTEM_CONFIG_STATUS" == "200" ]]; then
|
||||||
|
echo "PASS: Read system auth settings (HTTP $SYSTEM_CONFIG_STATUS)"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "FAIL: Read system auth settings (got $SYSTEM_CONFIG_STATUS)"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
ROLE_GRANTS_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-b "$ADMIN_COOKIE_JAR" "$BASE_URL/api/v1/admin/system-config/role-grants" || true)"
|
||||||
|
if [[ "$ROLE_GRANTS_STATUS" == "200" ]]; then
|
||||||
|
echo "PASS: Read initial role grant rules (HTTP $ROLE_GRANTS_STATUS)"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "FAIL: Read initial role grant rules (got $ROLE_GRANTS_STATUS)"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
# Create label definition
|
# Create label definition
|
||||||
CREATE_LABEL_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
|
CREATE_LABEL_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
|
||||||
-X POST "$BASE_URL/api/v1/admin/labels" \
|
-X POST "$BASE_URL/api/v1/admin/labels" \
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,23 @@
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<!-- Private local images use the official moderation upload token flow; the existing MinIO client cannot use that token. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.aliyun</groupId>
|
||||||
|
<artifactId>green20220302</artifactId>
|
||||||
|
<version>3.3.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.aliyun.oss</groupId>
|
||||||
|
<artifactId>aliyun-sdk-oss</artifactId>
|
||||||
|
<version>3.16.3</version>
|
||||||
|
<exclusions>
|
||||||
|
<exclusion>
|
||||||
|
<groupId>commons-logging</groupId>
|
||||||
|
<artifactId>commons-logging</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.iflytek.skillhub;
|
package com.iflytek.skillhub;
|
||||||
|
|
||||||
import com.iflytek.skillhub.bootstrap.BuiltinSkillProperties;
|
import com.iflytek.skillhub.bootstrap.BuiltinSkillProperties;
|
||||||
|
import com.iflytek.skillhub.config.AuthoringProperties;
|
||||||
import com.iflytek.skillhub.config.ProfileFieldPolicyProperties;
|
import com.iflytek.skillhub.config.ProfileFieldPolicyProperties;
|
||||||
import com.iflytek.skillhub.config.ProfileModerationProperties;
|
import com.iflytek.skillhub.config.ProfileModerationProperties;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
|
|
@ -14,7 +15,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||||
@EnableConfigurationProperties({
|
@EnableConfigurationProperties({
|
||||||
BuiltinSkillProperties.class,
|
BuiltinSkillProperties.class,
|
||||||
ProfileModerationProperties.class,
|
ProfileModerationProperties.class,
|
||||||
ProfileFieldPolicyProperties.class
|
ProfileFieldPolicyProperties.class,
|
||||||
|
AuthoringProperties.class
|
||||||
})
|
})
|
||||||
public class SkillhubApplication {
|
public class SkillhubApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
package com.iflytek.skillhub.bootstrap;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.iflytek.skillhub.auth.entity.Role;
|
||||||
|
import com.iflytek.skillhub.auth.repository.RoleRepository;
|
||||||
|
import com.iflytek.skillhub.auth.settings.ExternalRoleGrantRule;
|
||||||
|
import com.iflytek.skillhub.auth.settings.ExternalRoleGrantRuleRepository;
|
||||||
|
import com.iflytek.skillhub.auth.settings.InitialExternalRoleGrantService;
|
||||||
|
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
|
||||||
|
import com.iflytek.skillhub.auth.settings.SystemSetting;
|
||||||
|
import com.iflytek.skillhub.auth.settings.SystemSettingRepository;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.ConnectionCallback;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/** Seeds deployment defaults once, before other application runners can create a local admin. */
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
public class InitialAuthSettingsInitializer implements ApplicationRunner {
|
||||||
|
private static final String GRANT_SEED_MARKER = "auth.initial-role-grants.initialized";
|
||||||
|
private static final Pattern PROVIDER = Pattern.compile("[a-z0-9][a-z0-9_-]{0,63}");
|
||||||
|
private static final Pattern EMAIL = Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}");
|
||||||
|
|
||||||
|
private final InitialAuthSettingsProperties properties;
|
||||||
|
private final SystemSettingRepository settings;
|
||||||
|
private final ExternalRoleGrantRuleRepository rules;
|
||||||
|
private final RoleRepository roles;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public InitialAuthSettingsInitializer(InitialAuthSettingsProperties properties,
|
||||||
|
SystemSettingRepository settings,
|
||||||
|
ExternalRoleGrantRuleRepository rules,
|
||||||
|
RoleRepository roles,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
JdbcTemplate jdbcTemplate) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.settings = settings;
|
||||||
|
this.rules = rules;
|
||||||
|
this.roles = roles;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
// Serialize initialization across PostgreSQL replicas before checking missing rows.
|
||||||
|
jdbcTemplate.execute((ConnectionCallback<Void>) connection -> {
|
||||||
|
if ("PostgreSQL".equalsIgnoreCase(connection.getMetaData().getDatabaseProductName())) {
|
||||||
|
try (Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("SELECT pg_advisory_xact_lock(92320261010)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
Long existingUsers = null;
|
||||||
|
if (settings.findBySettingKey(LocalAuthSettingsService.SETTING_KEY).isEmpty()) {
|
||||||
|
existingUsers = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM user_account", Long.class);
|
||||||
|
boolean newInstallation = existingUsers == 0L;
|
||||||
|
settings.save(new SystemSetting(LocalAuthSettingsService.SETTING_KEY, Map.of(
|
||||||
|
LocalAuthSettingsService.PASSWORD_LOGIN_KEY,
|
||||||
|
newInstallation ? properties.isPasswordLoginEnabled() : true,
|
||||||
|
LocalAuthSettingsService.SELF_REGISTRATION_KEY,
|
||||||
|
newInstallation ? properties.isSelfRegistrationEnabled() : true)));
|
||||||
|
}
|
||||||
|
if (settings.findBySettingKey(GRANT_SEED_MARKER).isPresent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((existingUsers != null ? existingUsers
|
||||||
|
: jdbcTemplate.queryForObject("SELECT COUNT(*) FROM user_account", Long.class)) == 0L) {
|
||||||
|
seedInitialRules();
|
||||||
|
}
|
||||||
|
settings.save(new SystemSetting(GRANT_SEED_MARKER, Map.of("initialized", true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedInitialRules() {
|
||||||
|
List<SeedRule> initialRules;
|
||||||
|
try {
|
||||||
|
initialRules = objectMapper.readValue(properties.getRoleGrantsJson(), new TypeReference<>() {});
|
||||||
|
} catch (IOException | IllegalArgumentException exception) {
|
||||||
|
throw new IllegalStateException("Invalid initial external role grants JSON", exception);
|
||||||
|
}
|
||||||
|
if (initialRules == null) {
|
||||||
|
throw new IllegalStateException("Initial external role grants must be an array");
|
||||||
|
}
|
||||||
|
Set<String> identities = new HashSet<>();
|
||||||
|
for (SeedRule rule : initialRules) {
|
||||||
|
if (rule == null) throw new IllegalStateException("Initial role grant may not be null");
|
||||||
|
String provider = InitialExternalRoleGrantService.normalize(rule.provider());
|
||||||
|
String email = InitialExternalRoleGrantService.normalize(rule.email());
|
||||||
|
if (!PROVIDER.matcher(provider).matches() || !EMAIL.matcher(email).matches()) {
|
||||||
|
throw new IllegalStateException("Invalid initial role grant identity");
|
||||||
|
}
|
||||||
|
if ("feishu".equals(provider) || "dingtalk".equals(provider)) {
|
||||||
|
throw new IllegalStateException("Provider does not attest verified email for initial role grants: " + provider);
|
||||||
|
}
|
||||||
|
if (!identities.add(provider + ":" + email)) {
|
||||||
|
throw new IllegalStateException("Duplicate initial role grant identity");
|
||||||
|
}
|
||||||
|
Role role = roles.findByCode(rule.role())
|
||||||
|
.filter(Role::isSystem)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Invalid initial role grant role"));
|
||||||
|
rules.save(new ExternalRoleGrantRule(provider, email, role, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SeedRule(String provider, String email, String role) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.iflytek.skillhub.bootstrap;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "skillhub.auth.initial-settings")
|
||||||
|
public class InitialAuthSettingsProperties {
|
||||||
|
private boolean passwordLoginEnabled = true;
|
||||||
|
private boolean selfRegistrationEnabled = true;
|
||||||
|
private String roleGrantsJson = "[]";
|
||||||
|
|
||||||
|
public boolean isPasswordLoginEnabled() { return passwordLoginEnabled; }
|
||||||
|
public void setPasswordLoginEnabled(boolean enabled) { this.passwordLoginEnabled = enabled; }
|
||||||
|
public boolean isSelfRegistrationEnabled() { return selfRegistrationEnabled; }
|
||||||
|
public void setSelfRegistrationEnabled(boolean enabled) { this.selfRegistrationEnabled = enabled; }
|
||||||
|
public String getRoleGrantsJson() { return roleGrantsJson; }
|
||||||
|
public void setRoleGrantsJson(String roleGrantsJson) { this.roleGrantsJson = roleGrantsJson; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package com.iflytek.skillhub.config;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedicated executor for authoring validation runs. Kept separate from
|
||||||
|
* {@code skillhubEventExecutor} because validation tasks run short-lived subprocesses
|
||||||
|
* and blocking HTTP calls that would starve the lightweight event executor.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class AuthoringExecutorConfig {
|
||||||
|
|
||||||
|
@Bean(name = "authoringValidationTimer", destroyMethod = "shutdownNow")
|
||||||
|
public ScheduledExecutorService authoringValidationTimer() {
|
||||||
|
ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1, runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, "authoring-validation-timeout");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
timer.setRemoveOnCancelPolicy(true);
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "authoringValidationExecutor")
|
||||||
|
public Executor authoringValidationExecutor(AuthoringProperties properties) {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(Math.max(1, properties.getExecutorThreads()));
|
||||||
|
executor.setMaxPoolSize(Math.max(1, properties.getExecutorThreads()));
|
||||||
|
executor.setQueueCapacity(100);
|
||||||
|
executor.setThreadNamePrefix("authoring-validation-");
|
||||||
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package com.iflytek.skillhub.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature configuration for the skill authoring and validation platform
|
||||||
|
* ({@code skillhub.authoring.*}). This layer covers the draft authoring and
|
||||||
|
* structure-validation core; runtime-binding configuration (script execution
|
||||||
|
* backends, LLM endpoints, MCP servers) arrives with the runtime layer.
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "skillhub.authoring")
|
||||||
|
public class AuthoringProperties {
|
||||||
|
|
||||||
|
/** Root directory for per-run isolated working directories. */
|
||||||
|
private String workspaceRoot = System.getProperty("java.io.tmpdir") + "/skillhub-authoring";
|
||||||
|
|
||||||
|
/** Concurrent validation runs executed in-process. */
|
||||||
|
private int executorThreads = 4;
|
||||||
|
|
||||||
|
/** Deadline after execution starts; interrupts cooperatively and blocks late writes. */
|
||||||
|
private long runTimeoutMs = 900_000;
|
||||||
|
|
||||||
|
/** Active runs older than this are swept to TIMED_OUT by the maintenance task. */
|
||||||
|
private int staleRunMinutes = 30;
|
||||||
|
|
||||||
|
public String getWorkspaceRoot() {
|
||||||
|
return workspaceRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWorkspaceRoot(String workspaceRoot) {
|
||||||
|
this.workspaceRoot = workspaceRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getExecutorThreads() {
|
||||||
|
return executorThreads;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExecutorThreads(int executorThreads) {
|
||||||
|
this.executorThreads = executorThreads;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getRunTimeoutMs() {
|
||||||
|
return runTimeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRunTimeoutMs(long runTimeoutMs) {
|
||||||
|
this.runTimeoutMs = runTimeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStaleRunMinutes() {
|
||||||
|
return staleRunMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStaleRunMinutes(int staleRunMinutes) {
|
||||||
|
this.staleRunMinutes = staleRunMinutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,12 +5,14 @@ import com.iflytek.skillhub.auth.local.PasswordResetService;
|
||||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||||
import com.iflytek.skillhub.auth.session.PlatformSessionService;
|
import com.iflytek.skillhub.auth.session.PlatformSessionService;
|
||||||
|
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
|
||||||
import com.iflytek.skillhub.dto.ApiResponse;
|
import com.iflytek.skillhub.dto.ApiResponse;
|
||||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||||
import com.iflytek.skillhub.dto.AuthMeResponse;
|
import com.iflytek.skillhub.dto.AuthMeResponse;
|
||||||
import com.iflytek.skillhub.dto.ChangePasswordRequest;
|
import com.iflytek.skillhub.dto.ChangePasswordRequest;
|
||||||
import com.iflytek.skillhub.dto.LocalLoginRequest;
|
import com.iflytek.skillhub.dto.LocalLoginRequest;
|
||||||
import com.iflytek.skillhub.dto.LocalRegisterRequest;
|
import com.iflytek.skillhub.dto.LocalRegisterRequest;
|
||||||
|
import com.iflytek.skillhub.dto.LocalAuthCapabilitiesResponse;
|
||||||
import com.iflytek.skillhub.dto.PasswordResetConfirmRequest;
|
import com.iflytek.skillhub.dto.PasswordResetConfirmRequest;
|
||||||
import com.iflytek.skillhub.dto.PasswordResetRequestDto;
|
import com.iflytek.skillhub.dto.PasswordResetRequestDto;
|
||||||
import com.iflytek.skillhub.exception.UnauthorizedException;
|
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||||
|
|
@ -23,6 +25,7 @@ import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
@ -40,6 +43,7 @@ public class LocalAuthController extends BaseApiController {
|
||||||
private final AuthFailureThrottleService authFailureThrottleService;
|
private final AuthFailureThrottleService authFailureThrottleService;
|
||||||
private final PasswordResetService passwordResetService;
|
private final PasswordResetService passwordResetService;
|
||||||
private final AuthMeResponseAssembler authMeResponseAssembler;
|
private final AuthMeResponseAssembler authMeResponseAssembler;
|
||||||
|
private final LocalAuthSettingsService authSettings;
|
||||||
|
|
||||||
public LocalAuthController(ApiResponseFactory responseFactory,
|
public LocalAuthController(ApiResponseFactory responseFactory,
|
||||||
LocalAuthService localAuthService,
|
LocalAuthService localAuthService,
|
||||||
|
|
@ -47,7 +51,8 @@ public class LocalAuthController extends BaseApiController {
|
||||||
PlatformSessionService platformSessionService,
|
PlatformSessionService platformSessionService,
|
||||||
AuthFailureThrottleService authFailureThrottleService,
|
AuthFailureThrottleService authFailureThrottleService,
|
||||||
PasswordResetService passwordResetService,
|
PasswordResetService passwordResetService,
|
||||||
AuthMeResponseAssembler authMeResponseAssembler) {
|
AuthMeResponseAssembler authMeResponseAssembler,
|
||||||
|
LocalAuthSettingsService authSettings) {
|
||||||
super(responseFactory);
|
super(responseFactory);
|
||||||
this.localAuthService = localAuthService;
|
this.localAuthService = localAuthService;
|
||||||
this.skillHubMetrics = skillHubMetrics;
|
this.skillHubMetrics = skillHubMetrics;
|
||||||
|
|
@ -55,6 +60,14 @@ public class LocalAuthController extends BaseApiController {
|
||||||
this.authFailureThrottleService = authFailureThrottleService;
|
this.authFailureThrottleService = authFailureThrottleService;
|
||||||
this.passwordResetService = passwordResetService;
|
this.passwordResetService = passwordResetService;
|
||||||
this.authMeResponseAssembler = authMeResponseAssembler;
|
this.authMeResponseAssembler = authMeResponseAssembler;
|
||||||
|
this.authSettings = authSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/capabilities")
|
||||||
|
public ApiResponse<LocalAuthCapabilitiesResponse> capabilities() {
|
||||||
|
var current = authSettings.current();
|
||||||
|
return ok("response.success.read", new LocalAuthCapabilitiesResponse(
|
||||||
|
current.passwordLoginEnabled(), current.selfRegistrationEnabled(), current.registrationAvailable()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package com.iflytek.skillhub.controller.admin;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||||
|
import com.iflytek.skillhub.controller.BaseApiController;
|
||||||
|
import com.iflytek.skillhub.dto.ApiResponse;
|
||||||
|
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||||
|
import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse;
|
||||||
|
import com.iflytek.skillhub.dto.AvatarReviewSettingsUpdateRequest;
|
||||||
|
import com.iflytek.skillhub.service.AvatarReviewSettingsService;
|
||||||
|
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/system-config/avatar-review")
|
||||||
|
@PreAuthorize("hasRole('SUPER_ADMIN')")
|
||||||
|
public class AvatarReviewSettingsController extends BaseApiController {
|
||||||
|
private final AvatarReviewSettingsService service;
|
||||||
|
|
||||||
|
public AvatarReviewSettingsController(AvatarReviewSettingsService service, ApiResponseFactory responses) {
|
||||||
|
super(responses);
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<AvatarReviewSettingsResponse> current() {
|
||||||
|
return ok("response.success.read", service.current());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
public ApiResponse<AvatarReviewSettingsResponse> update(@Valid @RequestBody AvatarReviewSettingsUpdateRequest request,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest httpRequest) {
|
||||||
|
return ok("response.success.updated", service.update(request, principal.userId(),
|
||||||
|
AuditRequestContext.from(httpRequest)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
package com.iflytek.skillhub.controller.admin;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||||
|
import com.iflytek.skillhub.controller.BaseApiController;
|
||||||
|
import com.iflytek.skillhub.dto.ApiResponse;
|
||||||
|
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||||
|
import com.iflytek.skillhub.dto.ExternalRoleGrantCreateRequest;
|
||||||
|
import com.iflytek.skillhub.dto.ExternalRoleGrantRuleResponse;
|
||||||
|
import com.iflytek.skillhub.dto.ExternalRoleGrantUpdateRequest;
|
||||||
|
import com.iflytek.skillhub.dto.PlatformRoleResponse;
|
||||||
|
import com.iflytek.skillhub.dto.SystemAuthSettingsResponse;
|
||||||
|
import com.iflytek.skillhub.dto.SystemAuthSettingsUpdateRequest;
|
||||||
|
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||||
|
import com.iflytek.skillhub.service.SystemAuthSettingsAppService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/system-config")
|
||||||
|
@PreAuthorize("hasRole('SUPER_ADMIN')")
|
||||||
|
public class SystemAuthSettingsController extends BaseApiController {
|
||||||
|
private final SystemAuthSettingsAppService service;
|
||||||
|
|
||||||
|
public SystemAuthSettingsController(SystemAuthSettingsAppService service, ApiResponseFactory responseFactory) {
|
||||||
|
super(responseFactory);
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/auth/local")
|
||||||
|
public ApiResponse<SystemAuthSettingsResponse> getLocalSettings() {
|
||||||
|
return ok("response.success.read", service.getLocalSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/auth/local")
|
||||||
|
public ApiResponse<SystemAuthSettingsResponse> updateLocalSettings(
|
||||||
|
@Valid @RequestBody SystemAuthSettingsUpdateRequest request,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
return ok("response.success.updated", service.updateLocalSettings(
|
||||||
|
request, principal.userId(), AuditRequestContext.from(httpRequest)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/roles")
|
||||||
|
public ApiResponse<List<PlatformRoleResponse>> listRoles() {
|
||||||
|
return ok("response.success.read", service.listRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/role-grants")
|
||||||
|
public ApiResponse<List<ExternalRoleGrantRuleResponse>> listRoleGrants() {
|
||||||
|
return ok("response.success.read", service.listRules());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/role-grants")
|
||||||
|
public ApiResponse<ExternalRoleGrantRuleResponse> createRoleGrant(
|
||||||
|
@Valid @RequestBody ExternalRoleGrantCreateRequest request,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
return ok("response.success.created", service.createRule(
|
||||||
|
request, principal.userId(), AuditRequestContext.from(httpRequest)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/role-grants/{id}")
|
||||||
|
public ApiResponse<ExternalRoleGrantRuleResponse> updateRoleGrant(
|
||||||
|
@PathVariable long id,
|
||||||
|
@Valid @RequestBody ExternalRoleGrantUpdateRequest request,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
return ok("response.success.updated", service.updateRule(
|
||||||
|
id, request, principal.userId(), AuditRequestContext.from(httpRequest)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/role-grants/{id}")
|
||||||
|
public ApiResponse<ExternalRoleGrantRuleResponse> disableRoleGrant(
|
||||||
|
@PathVariable long id,
|
||||||
|
@RequestParam long version,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
return ok("response.success.updated", service.disableRule(
|
||||||
|
id, version, principal.userId(), AuditRequestContext.from(httpRequest)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
package com.iflytek.skillhub.controller.authoring;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.controller.BaseApiController;
|
||||||
|
import com.iflytek.skillhub.domain.authoring.SkillDraft;
|
||||||
|
import com.iflytek.skillhub.domain.authoring.service.SkillDraftService;
|
||||||
|
import com.iflytek.skillhub.domain.authoring.service.ValidationRunService;
|
||||||
|
import com.iflytek.skillhub.domain.authoring.validation.ValidationRun;
|
||||||
|
import com.iflytek.skillhub.dto.ApiResponse;
|
||||||
|
import com.iflytek.skillhub.dto.CreateDraftRequest;
|
||||||
|
import com.iflytek.skillhub.dto.DraftFileContentResponse;
|
||||||
|
import com.iflytek.skillhub.dto.DraftFileResponse;
|
||||||
|
import com.iflytek.skillhub.dto.DraftResponse;
|
||||||
|
import com.iflytek.skillhub.dto.SaveDraftFileRequest;
|
||||||
|
import com.iflytek.skillhub.dto.SaveDraftFileResponse;
|
||||||
|
import com.iflytek.skillhub.dto.ValidationEventResponse;
|
||||||
|
import com.iflytek.skillhub.dto.ValidationFindingResponse;
|
||||||
|
import com.iflytek.skillhub.dto.ValidationRunResponse;
|
||||||
|
import com.iflytek.skillhub.service.authoring.ValidationRunOrchestrator;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST endpoints for skill draft authoring and structure validation. Drafts
|
||||||
|
* own a SKILL.md scaffold and arbitrary resource files; validation runs
|
||||||
|
* execute the STRUCTURE layer and report findings with fix suggestions.
|
||||||
|
* Runtime binding, behavior execution and the publish integration arrive with
|
||||||
|
* the later layers.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping({"/api/v1/authoring", "/api/web/authoring"})
|
||||||
|
public class SkillAuthoringController extends BaseApiController {
|
||||||
|
|
||||||
|
private final SkillDraftService draftService;
|
||||||
|
private final ValidationRunService runService;
|
||||||
|
private final ValidationRunOrchestrator orchestrator;
|
||||||
|
|
||||||
|
public SkillAuthoringController(com.iflytek.skillhub.dto.ApiResponseFactory responseFactory,
|
||||||
|
SkillDraftService draftService,
|
||||||
|
ValidationRunService runService,
|
||||||
|
ValidationRunOrchestrator orchestrator) {
|
||||||
|
super(responseFactory);
|
||||||
|
this.draftService = draftService;
|
||||||
|
this.runService = runService;
|
||||||
|
this.orchestrator = orchestrator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- drafts
|
||||||
|
|
||||||
|
@Operation(operationId = "createAuthoringDraft", summary = "Create a draft seeded with a SKILL.md scaffold")
|
||||||
|
@PostMapping("/drafts")
|
||||||
|
public ApiResponse<DraftResponse> createDraft(@Valid
|
||||||
|
@RequestBody CreateDraftRequest request,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
SkillDraft draft = draftService.createDraft(
|
||||||
|
request.namespaceSlug(), userId, request.name(), request.requirement(), platformRoles);
|
||||||
|
return ok("response.success.created", DraftResponse.from(draft));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(operationId = "listAuthoringDrafts", summary = "List the current user's drafts")
|
||||||
|
@GetMapping("/drafts")
|
||||||
|
public ApiResponse<List<DraftResponse>> listDrafts(@RequestAttribute("userId") String userId) {
|
||||||
|
return ok("response.success", draftService.listDrafts(userId).stream()
|
||||||
|
.map(DraftResponse::from)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(operationId = "getAuthoringDraft", summary = "Get one draft owned by the current user")
|
||||||
|
@GetMapping("/drafts/{draftId}")
|
||||||
|
public ApiResponse<DraftResponse> getDraft(@PathVariable Long draftId,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
return ok("response.success",
|
||||||
|
DraftResponse.from(draftService.getOwnedDraft(draftId, userId, platformRoles)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(operationId = "deleteAuthoringDraft", summary = "Delete a draft and its files")
|
||||||
|
@DeleteMapping("/drafts/{draftId}")
|
||||||
|
public ApiResponse<Void> deleteDraft(@PathVariable Long draftId,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
draftService.deleteDraft(draftId, userId, platformRoles);
|
||||||
|
return ok("response.success.deleted", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- draft files
|
||||||
|
|
||||||
|
@Operation(operationId = "listDraftFiles", summary = "List a draft's files with metadata")
|
||||||
|
@GetMapping("/drafts/{draftId}/files")
|
||||||
|
public ApiResponse<List<DraftFileResponse>> listFiles(@PathVariable Long draftId,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
draftService.getOwnedDraft(draftId, userId, platformRoles);
|
||||||
|
return ok("response.success", draftService.listFiles(draftId).stream()
|
||||||
|
.map(DraftFileResponse::from)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(operationId = "saveDraftFile", summary = "Create or update one draft file")
|
||||||
|
@PutMapping("/drafts/{draftId}/files")
|
||||||
|
public ApiResponse<SaveDraftFileResponse> saveFile(@PathVariable Long draftId,
|
||||||
|
@Valid
|
||||||
|
@RequestBody SaveDraftFileRequest request,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
byte[] content = request.isBase64()
|
||||||
|
? Base64.getDecoder().decode(request.content())
|
||||||
|
: request.content().getBytes(StandardCharsets.UTF_8);
|
||||||
|
return ok("response.success.updated", SaveDraftFileResponse.from(
|
||||||
|
draftService.saveFile(draftId, userId, request.path(), content,
|
||||||
|
request.contentType(), request.expectedRevision(), platformRoles)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(operationId = "readDraftFile", summary = "Read one draft file's content")
|
||||||
|
@GetMapping("/drafts/{draftId}/files/content")
|
||||||
|
public ApiResponse<DraftFileContentResponse> readFile(@PathVariable Long draftId,
|
||||||
|
@RequestParam("path") String path,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
SkillDraftService.FileContent content =
|
||||||
|
draftService.readFile(draftId, userId, path, platformRoles);
|
||||||
|
return ok("response.success", DraftFileContentResponse.from(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(operationId = "deleteDraftFile", summary = "Delete one draft file")
|
||||||
|
@DeleteMapping("/drafts/{draftId}/files")
|
||||||
|
public ApiResponse<Void> deleteFile(@PathVariable Long draftId,
|
||||||
|
@RequestParam("path") String path,
|
||||||
|
@RequestParam(value = "expectedRevision", required = false)
|
||||||
|
Integer expectedRevision,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
draftService.deleteFile(draftId, userId, path, expectedRevision, platformRoles);
|
||||||
|
return ok("response.success.deleted", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- validation runs
|
||||||
|
|
||||||
|
@Operation(operationId = "startValidationRun", summary = "Start a validation run for the draft's current revision")
|
||||||
|
@PostMapping("/drafts/{draftId}/runs")
|
||||||
|
public ApiResponse<ValidationRunResponse> startRun(@PathVariable Long draftId,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
ValidationRun run = runService.startRun(draftId, userId, platformRoles);
|
||||||
|
orchestrator.submit(run.getId());
|
||||||
|
return ok("response.success.created", ValidationRunResponse.from(run));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(operationId = "listValidationRuns", summary = "List the draft's validation runs")
|
||||||
|
@GetMapping("/drafts/{draftId}/runs")
|
||||||
|
public ApiResponse<List<ValidationRunResponse>> listRuns(@PathVariable Long draftId,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
draftService.getOwnedDraft(draftId, userId, platformRoles);
|
||||||
|
return ok("response.success", runService.listRuns(draftId).stream()
|
||||||
|
.map(ValidationRunResponse::from)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(operationId = "getValidationRun", summary = "Get one validation run")
|
||||||
|
@GetMapping("/runs/{runId}")
|
||||||
|
public ApiResponse<ValidationRunResponse> getRun(@PathVariable Long runId,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
ValidationRun run = runService.getOwnedRun(runId, userId, platformRoles);
|
||||||
|
return ok("response.success", ValidationRunResponse.from(run));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(operationId = "cancelValidationRun", summary = "Request cooperative cancellation of a run")
|
||||||
|
@PostMapping("/runs/{runId}/cancel")
|
||||||
|
public ApiResponse<ValidationRunResponse> cancelRun(@PathVariable Long runId,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
ValidationRun run = orchestrator.requestCancel(runId, userId, platformRoles);
|
||||||
|
return ok("response.success.updated", ValidationRunResponse.from(run));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- events & findings
|
||||||
|
|
||||||
|
@Operation(operationId = "listValidationEvents", summary = "List run events after a sequence cursor (polling)")
|
||||||
|
@GetMapping("/runs/{runId}/events")
|
||||||
|
public ApiResponse<List<ValidationEventResponse>> listEvents(
|
||||||
|
@PathVariable Long runId,
|
||||||
|
@RequestParam(value = "afterSeq", required = false) Integer afterSeq,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
runService.getOwnedRun(runId, userId, platformRoles);
|
||||||
|
return ok("response.success", runService.listEvents(runId, afterSeq).stream()
|
||||||
|
.map(ValidationEventResponse::from)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(operationId = "listValidationFindings", summary = "List a run's findings with fix suggestions")
|
||||||
|
@GetMapping("/runs/{runId}/findings")
|
||||||
|
public ApiResponse<List<ValidationFindingResponse>> listFindings(
|
||||||
|
@PathVariable Long runId,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
runService.getOwnedRun(runId, userId, platformRoles);
|
||||||
|
return ok("response.success", runService.listFindings(runId).stream()
|
||||||
|
.map(ValidationFindingResponse::from)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
package com.iflytek.skillhub.controller.authoring;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.domain.authoring.service.ValidationRunService;
|
||||||
|
import com.iflytek.skillhub.domain.authoring.validation.ValidationRun;
|
||||||
|
import com.iflytek.skillhub.service.authoring.ValidationEventBroadcaster;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-sent events for validation runs. Browsers authenticate via the session
|
||||||
|
* cookie (EventSource cannot send headers); token clients use the /api/v1 variant.
|
||||||
|
* Event ids are the per-run sequence numbers, so reconnecting clients resume from
|
||||||
|
* Last-Event-ID without gaps or duplicates.
|
||||||
|
*/
|
||||||
|
@Tag(name = "Skill authoring")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping({"/api/v1/authoring", "/api/web/authoring"})
|
||||||
|
public class ValidationStreamController {
|
||||||
|
|
||||||
|
private final ValidationRunService runService;
|
||||||
|
private final ValidationEventBroadcaster broadcaster;
|
||||||
|
|
||||||
|
public ValidationStreamController(ValidationRunService runService,
|
||||||
|
ValidationEventBroadcaster broadcaster) {
|
||||||
|
this.runService = runService;
|
||||||
|
this.broadcaster = broadcaster;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(operationId = "streamValidationEvents",
|
||||||
|
summary = "Stream run events over SSE; resumes from Last-Event-ID")
|
||||||
|
@GetMapping(value = "/runs/{runId}/events/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public SseEmitter streamEvents(@PathVariable Long runId,
|
||||||
|
@RequestParam(value = "afterSeq", required = false) Integer afterSeq,
|
||||||
|
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId,
|
||||||
|
@RequestAttribute("userId") String userId,
|
||||||
|
@RequestAttribute(value = "platformRoles", required = false)
|
||||||
|
Set<String> platformRoles) {
|
||||||
|
ValidationRun run = runService.getOwnedRun(runId, userId, platformRoles);
|
||||||
|
int lastSeq = resolveCursor(afterSeq, lastEventId);
|
||||||
|
return broadcaster.subscribe(run.getId(), lastSeq,
|
||||||
|
() -> runService.listEvents(run.getId(), lastSeq));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int resolveCursor(Integer afterSeq, String lastEventId) {
|
||||||
|
int fromParam = afterSeq == null ? 0 : Math.max(0, afterSeq);
|
||||||
|
int fromHeader = 0;
|
||||||
|
if (lastEventId != null && !lastEventId.isBlank()) {
|
||||||
|
try {
|
||||||
|
fromHeader = Math.max(0, Integer.parseInt(lastEventId.trim()));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// malformed Last-Event-ID restarts from the beginning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.max(fromParam, fromHeader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
package com.iflytek.skillhub.controller.portal;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||||
|
import com.iflytek.skillhub.controller.BaseApiController;
|
||||||
|
import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService;
|
||||||
|
import com.iflytek.skillhub.dto.ApiResponse;
|
||||||
|
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||||
|
import com.iflytek.skillhub.dto.MessageResponse;
|
||||||
|
import com.iflytek.skillhub.dto.NamespaceInvitationTokenRequest;
|
||||||
|
import com.iflytek.skillhub.dto.NamespaceInvitationCreateRequest;
|
||||||
|
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping({"/api/v1", "/api/web"})
|
||||||
|
public class NamespaceInvitationController extends BaseApiController {
|
||||||
|
private final NamespaceInvitationService invitations;
|
||||||
|
|
||||||
|
public NamespaceInvitationController(NamespaceInvitationService invitations, ApiResponseFactory responseFactory) {
|
||||||
|
super(responseFactory);
|
||||||
|
this.invitations = invitations;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/namespaces/{slug}/invitations")
|
||||||
|
public ApiResponse<InvitationStatus> status(@PathVariable String slug, @AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
noStore(response);
|
||||||
|
requireSession(principal, request);
|
||||||
|
return ok("response.success.read", new InvitationStatus(invitations.hasActiveInvitation(slug, principal.userId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/namespaces/{slug}/invitations/history")
|
||||||
|
public ApiResponse<InvitationHistoryPage> history(@PathVariable String slug,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
noStore(response);
|
||||||
|
requireSession(principal, request);
|
||||||
|
var rows = invitations.history(slug, principal.userId(), page, size);
|
||||||
|
var now = invitations.currentTime();
|
||||||
|
return ok("response.success.read", new InvitationHistoryPage(rows.getContent().stream()
|
||||||
|
.map(row -> new InvitationHistoryItem(row.getId(), row.getIssuedBy(), row.getIssuedByName(),
|
||||||
|
row.getIssuedAt(), row.getExpiresAt(), row.status(now), row.getConsumedBy(), row.getConsumedByName(),
|
||||||
|
row.getConsumedAt(), row.getEndedAt(), row.isLegacy())).toList(),
|
||||||
|
rows.getNumber(), rows.getSize(), rows.getTotalElements(), rows.getTotalPages()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record InvitationHistoryItem(String invitationId, String issuedBy, String issuedByName,
|
||||||
|
Instant issuedAt, Instant expiresAt, String status, String consumedBy,
|
||||||
|
String consumedByName, Instant consumedAt, Instant endedAt, boolean legacy) {}
|
||||||
|
public record InvitationHistoryPage(List<InvitationHistoryItem> items, int page, int size,
|
||||||
|
long total, int totalPages) {}
|
||||||
|
|
||||||
|
@PostMapping("/namespaces/{slug}/invitations")
|
||||||
|
public ApiResponse<NamespaceInvitationService.Created> create(@PathVariable String slug,
|
||||||
|
@RequestBody(required = false) NamespaceInvitationCreateRequest body,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
noStore(response);
|
||||||
|
requireSession(principal, request);
|
||||||
|
return ok("response.success.created", invitations.create(slug, principal.userId(), body != null && body.replace()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/namespaces/{slug}/invitations")
|
||||||
|
public ApiResponse<MessageResponse> revoke(@PathVariable String slug,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
noStore(response);
|
||||||
|
requireSession(principal, request);
|
||||||
|
invitations.revoke(slug, principal.userId());
|
||||||
|
return ok("response.success.deleted", new MessageResponse("Invitation revoked"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/namespace-invitations/preview")
|
||||||
|
public ApiResponse<NamespaceInvitationService.Preview> preview(@Valid @RequestBody NamespaceInvitationTokenRequest body,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
noStore(response);
|
||||||
|
requireSession(principal, request);
|
||||||
|
return ok("response.success.read", invitations.preview(body.token(), principal.userId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/namespace-invitations/accept")
|
||||||
|
public ApiResponse<NamespaceInvitationService.Accepted> accept(@Valid @RequestBody NamespaceInvitationTokenRequest body,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
noStore(response);
|
||||||
|
requireSession(principal, request);
|
||||||
|
return ok("response.success.updated", invitations.accept(body.token(), principal.userId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireSession(PlatformPrincipal principal, HttpServletRequest request) {
|
||||||
|
if (principal == null || "api_token".equals(principal.oauthProvider()) || request.getSession(false) == null) {
|
||||||
|
throw new UnauthorizedException("error.auth.required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void noStore(HttpServletResponse response) {
|
||||||
|
response.setHeader("Cache-Control", "no-store, private");
|
||||||
|
response.setHeader("Pragma", "no-cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
public record InvitationStatus(boolean active) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
package com.iflytek.skillhub.controller.portal;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||||
|
import com.iflytek.skillhub.controller.BaseApiController;
|
||||||
|
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||||
|
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||||
|
import com.iflytek.skillhub.dto.ApiResponse;
|
||||||
|
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||||
|
import com.iflytek.skillhub.dto.SkillAvatarDecisionRequest;
|
||||||
|
import com.iflytek.skillhub.dto.SkillAvatarReasonRequest;
|
||||||
|
import com.iflytek.skillhub.dto.SkillAvatarStatusResponse;
|
||||||
|
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||||
|
import com.iflytek.skillhub.service.SkillAvatarAppService;
|
||||||
|
import com.iflytek.skillhub.service.SkillAvatarUploadService;
|
||||||
|
import com.iflytek.skillhub.dto.SkillAvatarUploadResponse;
|
||||||
|
import com.iflytek.skillhub.dto.SkillAvatarSubmitRequest;
|
||||||
|
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||||
|
public class SkillAvatarController extends BaseApiController {
|
||||||
|
private final SkillAvatarAppService service;
|
||||||
|
private final SkillAvatarUploadService uploads;
|
||||||
|
|
||||||
|
public SkillAvatarController(SkillAvatarAppService service, SkillAvatarUploadService uploads, ApiResponseFactory responses) {
|
||||||
|
super(responses);
|
||||||
|
this.service = service;
|
||||||
|
this.uploads = uploads;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/avatar-uploads", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@RateLimit(category = "publish", authenticated = 10, anonymous = 0)
|
||||||
|
public ApiResponse<SkillAvatarUploadResponse> upload(@RequestParam("file") MultipartFile file,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal) throws IOException {
|
||||||
|
if (file.getSize() < 1 || file.getSize() > com.iflytek.skillhub.service.SkillAvatarImageProcessor.MAX_BYTES) {
|
||||||
|
throw new DomainBadRequestException("error.skill.avatar.invalidImage");
|
||||||
|
}
|
||||||
|
return ok("response.success.created", uploads.upload(userId(principal), file.getBytes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/avatar-uploads/{uploadId}/image")
|
||||||
|
public ResponseEntity<InputStreamResource> uploadPreview(@PathVariable String uploadId,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||||
|
return png(uploads.preview(uploadId, userId(principal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/{namespace}/{slug}/avatar", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public ApiResponse<SkillAvatarStatusResponse> submitUpload(@PathVariable String namespace, @PathVariable String slug,
|
||||||
|
@Valid @RequestBody SkillAvatarSubmitRequest input, @AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return ok("response.success.created", uploads.submit(namespace, slug, userId(principal), input.uploadId(),
|
||||||
|
AuditRequestContext.from(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{namespace}/{slug}/avatar")
|
||||||
|
public ResponseEntity<InputStreamResource> current(@PathVariable String namespace, @PathVariable String slug,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles) {
|
||||||
|
InputStream image = service.readActive(namespace, slug, userId(principal), roles, platformRoles(principal));
|
||||||
|
return png(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/{namespace}/{slug}/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public ApiResponse<SkillAvatarStatusResponse> submit(@PathVariable String namespace, @PathVariable String slug,
|
||||||
|
@RequestParam("file") MultipartFile file, @AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
HttpServletRequest httpRequest) throws IOException {
|
||||||
|
if (file.getSize() < 1 || file.getSize() > com.iflytek.skillhub.service.SkillAvatarImageProcessor.MAX_BYTES) {
|
||||||
|
throw new DomainBadRequestException("error.skill.avatar.invalidImage");
|
||||||
|
}
|
||||||
|
AuditRequestContext context = AuditRequestContext.from(httpRequest);
|
||||||
|
SkillAvatarStatusResponse created = service.submit(namespace, slug, userId(principal), file.getBytes(), context);
|
||||||
|
if ("PENDING".equals(created.machineStatus()) && "PENDING".equals(created.candidateStatus())) {
|
||||||
|
return ok("response.success.created", service.processMachine(created.candidateId(), context));
|
||||||
|
}
|
||||||
|
return ok("response.success.created", created);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{namespace}/{slug}/avatar/status")
|
||||||
|
public ApiResponse<SkillAvatarStatusResponse> status(@PathVariable String namespace, @PathVariable String slug,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||||
|
return ok("response.success.read", service.ownerStatus(namespace, slug, userId(principal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/image")
|
||||||
|
public ResponseEntity<InputStreamResource> candidate(@PathVariable String namespace, @PathVariable String slug,
|
||||||
|
@PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles) {
|
||||||
|
return png(service.readCandidate(namespace, slug, candidateId, userId(principal), roles, platformRoles(principal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/appeal")
|
||||||
|
public ApiResponse<SkillAvatarStatusResponse> appeal(@PathVariable String namespace, @PathVariable String slug,
|
||||||
|
@PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return ok("response.success.updated", service.appeal(namespace, slug, candidateId,
|
||||||
|
userId(principal), AuditRequestContext.from(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{namespace}/{slug}/avatar/candidates/{candidateId}/retry")
|
||||||
|
public ApiResponse<SkillAvatarStatusResponse> retry(@PathVariable String namespace, @PathVariable String slug,
|
||||||
|
@PathVariable long candidateId, @AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
service.requireCurrentOwnerCandidate(namespace, slug, candidateId, userId(principal));
|
||||||
|
return ok("response.success.updated", service.processMachine(candidateId, AuditRequestContext.from(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated
|
||||||
|
public ApiResponse<List<SkillAvatarStatusResponse>> queue(
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles) {
|
||||||
|
return ok("response.success.read", service.reviewQueue(userId(principal), roles, platformRoles(principal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated
|
||||||
|
public ApiResponse<SkillAvatarStatusResponse> decide(@PathVariable String namespace, @PathVariable String slug,
|
||||||
|
@PathVariable long candidateId, @Valid @RequestBody SkillAvatarDecisionRequest decision,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return ok("response.success.updated", service.decide(namespace, slug, candidateId,
|
||||||
|
decision.approved(), decision.reason(), userId(principal), roles, platformRoles(principal),
|
||||||
|
AuditRequestContext.from(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{namespace}/{slug}/avatar")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable String namespace, @PathVariable String slug,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
service.remove(namespace, slug, userId(principal), false, null, roles, platformRoles(principal),
|
||||||
|
AuditRequestContext.from(request));
|
||||||
|
return ok("response.success.updated", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{namespace}/{slug}/avatar/takedown")
|
||||||
|
public ApiResponse<Void> takedown(@PathVariable String namespace, @PathVariable String slug,
|
||||||
|
@Valid @RequestBody SkillAvatarReasonRequest input, @AuthenticationPrincipal PlatformPrincipal principal,
|
||||||
|
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
service.remove(namespace, slug, userId(principal), true, input.reason(), roles, platformRoles(principal),
|
||||||
|
AuditRequestContext.from(request));
|
||||||
|
return ok("response.success.updated", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<InputStreamResource> png(InputStream input) {
|
||||||
|
return ResponseEntity.ok().contentType(MediaType.IMAGE_PNG).cacheControl(CacheControl.noStore())
|
||||||
|
.body(new InputStreamResource(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String userId(PlatformPrincipal principal) {
|
||||||
|
return principal == null ? null : principal.userId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> platformRoles(PlatformPrincipal principal) {
|
||||||
|
return principal == null || principal.platformRoles() == null ? Set.of() : principal.platformRoles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,8 @@ import com.iflytek.skillhub.dto.SkillVersionResponse;
|
||||||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||||
import com.iflytek.skillhub.service.SkillLabelAppService;
|
import com.iflytek.skillhub.service.SkillLabelAppService;
|
||||||
|
import com.iflytek.skillhub.service.SkillAvatarUrls;
|
||||||
|
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||||
import com.iflytek.skillhub.service.ComplianceSnapshotProjectionService;
|
import com.iflytek.skillhub.service.ComplianceSnapshotProjectionService;
|
||||||
import com.iflytek.skillhub.service.SkillSuiteAppService;
|
import com.iflytek.skillhub.service.SkillSuiteAppService;
|
||||||
import org.springframework.core.io.InputStreamResource;
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
|
@ -127,7 +129,11 @@ public class SkillController extends BaseApiController {
|
||||||
memberOfSuites.items().stream()
|
memberOfSuites.items().stream()
|
||||||
.filter(SkillSuiteReferenceResponse::currentSkillEntry)
|
.filter(SkillSuiteReferenceResponse::currentSkillEntry)
|
||||||
.toList(),
|
.toList(),
|
||||||
memberOfSuites
|
memberOfSuites,
|
||||||
|
SkillVisibility.PRIVATE == SkillVisibility.valueOf(detail.visibility())
|
||||||
|
&& !detail.ownerId().equals(userId) ? null
|
||||||
|
: SkillAvatarUrls.current(detail.activeAvatarId(), detail.activeAvatarAudience(),
|
||||||
|
SkillVisibility.valueOf(detail.visibility()), namespace, detail.slug())
|
||||||
);
|
);
|
||||||
|
|
||||||
return ok("response.success.read", response);
|
return ok("response.success.read", response);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import com.iflytek.skillhub.dto.ApiResponse;
|
||||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||||
import com.iflytek.skillhub.dto.PublishResponse;
|
import com.iflytek.skillhub.dto.PublishResponse;
|
||||||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||||
|
import com.iflytek.skillhub.service.SkillPublishAppService;
|
||||||
|
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
@ -29,11 +32,11 @@ import java.util.List;
|
||||||
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
|
||||||
public class SkillPublishController extends BaseApiController {
|
public class SkillPublishController extends BaseApiController {
|
||||||
|
|
||||||
private final SkillPublishService skillPublishService;
|
private final SkillPublishAppService skillPublishService;
|
||||||
private final SkillPackageArchiveExtractor skillPackageArchiveExtractor;
|
private final SkillPackageArchiveExtractor skillPackageArchiveExtractor;
|
||||||
private final SkillHubMetrics skillHubMetrics;
|
private final SkillHubMetrics skillHubMetrics;
|
||||||
|
|
||||||
public SkillPublishController(SkillPublishService skillPublishService,
|
public SkillPublishController(SkillPublishAppService skillPublishService,
|
||||||
SkillPackageArchiveExtractor skillPackageArchiveExtractor,
|
SkillPackageArchiveExtractor skillPackageArchiveExtractor,
|
||||||
ApiResponseFactory responseFactory,
|
ApiResponseFactory responseFactory,
|
||||||
SkillHubMetrics skillHubMetrics) {
|
SkillHubMetrics skillHubMetrics) {
|
||||||
|
|
@ -54,7 +57,10 @@ public class SkillPublishController extends BaseApiController {
|
||||||
@RequestParam("file") MultipartFile file,
|
@RequestParam("file") MultipartFile file,
|
||||||
@RequestParam("visibility") String visibility,
|
@RequestParam("visibility") String visibility,
|
||||||
@RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings,
|
@RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings,
|
||||||
@AuthenticationPrincipal PlatformPrincipal principal) throws IOException {
|
@RequestParam(value = "avatarAction", defaultValue = "KEEP") SkillPublishAppService.AvatarAction avatarAction,
|
||||||
|
@RequestParam(value = "avatarUploadId", required = false) String avatarUploadId,
|
||||||
|
@RequestParam(value = "expectedSlug", required = false) String expectedSlug,
|
||||||
|
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) throws IOException {
|
||||||
|
|
||||||
SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase());
|
SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase());
|
||||||
|
|
||||||
|
|
@ -75,13 +81,13 @@ public class SkillPublishController extends BaseApiController {
|
||||||
String.join("\n", extractionWarnings));
|
String.join("\n", extractionWarnings));
|
||||||
}
|
}
|
||||||
|
|
||||||
SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries(
|
SkillPublishService.PublishResult publishResult = skillPublishService.publish(
|
||||||
namespace,
|
namespace,
|
||||||
entries,
|
entries,
|
||||||
principal.userId(),
|
principal.userId(),
|
||||||
skillVisibility,
|
skillVisibility,
|
||||||
principal.platformRoles(),
|
principal.platformRoles(),
|
||||||
confirmWarnings
|
confirmWarnings, avatarAction, avatarUploadId, expectedSlug, AuditRequestContext.from(request)
|
||||||
);
|
);
|
||||||
|
|
||||||
PublishResponse response = new PublishResponse(
|
PublishResponse response = new PublishResponse(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record AvatarReviewSettingsResponse(boolean machineEnabled, boolean manualEnabled,
|
||||||
|
long version, Instant updatedAt) {}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
|
||||||
|
public record AvatarReviewSettingsUpdateRequest(boolean machineEnabled, boolean manualEnabled,
|
||||||
|
@Min(0) long version) {}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/** Request body for creating a skill authoring draft. */
|
||||||
|
public record CreateDraftRequest(
|
||||||
|
@NotBlank(message = "{error.badRequest}") String namespaceSlug,
|
||||||
|
@NotBlank(message = "{error.badRequest}") String name,
|
||||||
|
String requirement
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.domain.authoring.service.SkillDraftService;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.charset.CharacterCodingException;
|
||||||
|
import java.nio.charset.CodingErrorAction;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/** UTF-8 text stays editable; other file bytes use reversible base64 transport. */
|
||||||
|
public record DraftFileContentResponse(
|
||||||
|
String path,
|
||||||
|
String sha256,
|
||||||
|
Long size,
|
||||||
|
String contentType,
|
||||||
|
String content,
|
||||||
|
String encoding
|
||||||
|
) {
|
||||||
|
public static DraftFileContentResponse from(SkillDraftService.FileContent fileContent) {
|
||||||
|
String content;
|
||||||
|
String encoding;
|
||||||
|
try {
|
||||||
|
content = StandardCharsets.UTF_8.newDecoder()
|
||||||
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
|
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||||
|
.decode(ByteBuffer.wrap(fileContent.content())).toString();
|
||||||
|
encoding = "text";
|
||||||
|
} catch (CharacterCodingException exception) {
|
||||||
|
content = Base64.getEncoder().encodeToString(fileContent.content());
|
||||||
|
encoding = "base64";
|
||||||
|
}
|
||||||
|
var file = fileContent.file();
|
||||||
|
return new DraftFileContentResponse(file.getFilePath(), file.getSha256(), file.getSize(),
|
||||||
|
file.getContentType(), content, encoding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.domain.authoring.DraftFile;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** Draft file metadata (content is fetched separately). */
|
||||||
|
public record DraftFileResponse(
|
||||||
|
Long id,
|
||||||
|
String path,
|
||||||
|
String sha256,
|
||||||
|
Long size,
|
||||||
|
String contentType,
|
||||||
|
Instant updatedAt
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static DraftFileResponse from(DraftFile file) {
|
||||||
|
return new DraftFileResponse(
|
||||||
|
file.getId(),
|
||||||
|
file.getFilePath(),
|
||||||
|
file.getSha256(),
|
||||||
|
file.getSize(),
|
||||||
|
file.getContentType(),
|
||||||
|
file.getUpdatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.domain.authoring.SkillDraft;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** Draft summary/aggregate exposed to the authoring UI. */
|
||||||
|
public record DraftResponse(
|
||||||
|
Long id,
|
||||||
|
Long namespaceId,
|
||||||
|
String name,
|
||||||
|
String requirement,
|
||||||
|
Integer revision,
|
||||||
|
String contentDigest,
|
||||||
|
boolean validated,
|
||||||
|
Integer validatedRevision,
|
||||||
|
Long validatedRunId,
|
||||||
|
Long submittedSkillId,
|
||||||
|
Long submittedVersionId,
|
||||||
|
Instant submittedAt,
|
||||||
|
Instant createdAt,
|
||||||
|
Instant updatedAt
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static DraftResponse from(SkillDraft draft) {
|
||||||
|
return new DraftResponse(
|
||||||
|
draft.getId(),
|
||||||
|
draft.getNamespaceId(),
|
||||||
|
draft.getName(),
|
||||||
|
draft.getRequirement(),
|
||||||
|
draft.getRevision(),
|
||||||
|
draft.getContentDigest(),
|
||||||
|
draft.isCurrentRevisionValidated(),
|
||||||
|
draft.getValidatedRevision(),
|
||||||
|
draft.getValidatedRunId(),
|
||||||
|
draft.getSubmittedSkillId(),
|
||||||
|
draft.getSubmittedVersionId(),
|
||||||
|
draft.getSubmittedAt(),
|
||||||
|
draft.getCreatedAt(),
|
||||||
|
draft.getUpdatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public record ExternalRoleGrantCreateRequest(
|
||||||
|
@NotBlank @Size(max = 64) String providerCode,
|
||||||
|
@NotBlank @Size(max = 256) String email,
|
||||||
|
@NotBlank @Size(max = 64) String roleCode
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record ExternalRoleGrantRuleResponse(
|
||||||
|
long id,
|
||||||
|
String providerCode,
|
||||||
|
String email,
|
||||||
|
String roleCode,
|
||||||
|
String status,
|
||||||
|
String matchedSubject,
|
||||||
|
String grantedUserId,
|
||||||
|
Instant grantedAt,
|
||||||
|
long version,
|
||||||
|
Instant updatedAt
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record ExternalRoleGrantUpdateRequest(
|
||||||
|
@NotBlank String roleCode,
|
||||||
|
@NotNull Long version
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
public record LocalAuthCapabilitiesResponse(
|
||||||
|
boolean passwordLoginEnabled,
|
||||||
|
boolean selfRegistrationEnabled,
|
||||||
|
boolean registrationAvailable
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
public record NamespaceInvitationCreateRequest(boolean replace) {}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record NamespaceInvitationTokenRequest(@NotBlank String token) {}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
public record PlatformRoleResponse(String code, String name) {}
|
||||||
|
|
@ -9,5 +9,14 @@ public record ReviewSkillDetailResponse(
|
||||||
String documentationPath,
|
String documentationPath,
|
||||||
String documentationContent,
|
String documentationContent,
|
||||||
String downloadUrl,
|
String downloadUrl,
|
||||||
String activeVersion
|
String activeVersion,
|
||||||
) {}
|
AvatarChange avatarChange
|
||||||
|
) {
|
||||||
|
public record AvatarChange(Long candidateId, String currentAvatarUrl, String candidatePreviewUrl,
|
||||||
|
String machineStatus, String reviewStatus, boolean readyForApproval, boolean removal) {}
|
||||||
|
public ReviewSkillDetailResponse(SkillDetailResponse skill, List<SkillVersionResponse> versions,
|
||||||
|
List<SkillFileResponse> files, String documentationPath, String documentationContent,
|
||||||
|
String downloadUrl, String activeVersion) {
|
||||||
|
this(skill, versions, files, documentationPath, documentationContent, downloadUrl, activeVersion, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,19 @@ public record ReviewTaskResponse(
|
||||||
String subjectType,
|
String subjectType,
|
||||||
Long subjectId,
|
Long subjectId,
|
||||||
Long subjectVersionId,
|
Long subjectVersionId,
|
||||||
String subjectSlug
|
String subjectSlug,
|
||||||
|
Long avatarCandidateId,
|
||||||
|
boolean avatarRemoval
|
||||||
) {
|
) {
|
||||||
|
public ReviewTaskResponse(Long id, Long skillVersionId, String namespace, String skillSlug, String version,
|
||||||
|
String status, String submittedBy, String submittedByName, String reviewedBy, String reviewedByName,
|
||||||
|
String reviewComment, Instant submittedAt, Instant reviewedAt, String subjectType, Long subjectId,
|
||||||
|
Long subjectVersionId, String subjectSlug) {
|
||||||
|
this(id, skillVersionId, namespace, skillSlug, version, status, submittedBy, submittedByName,
|
||||||
|
reviewedBy, reviewedByName, reviewComment, submittedAt, reviewedAt, subjectType, subjectId,
|
||||||
|
subjectVersionId, subjectSlug, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
/** Backward-compatible constructor for Skill-only callers and tests. */
|
/** Backward-compatible constructor for Skill-only callers and tests. */
|
||||||
public ReviewTaskResponse(
|
public ReviewTaskResponse(
|
||||||
Long id,
|
Long id,
|
||||||
|
|
@ -40,6 +51,6 @@ public record ReviewTaskResponse(
|
||||||
this(id, skillVersionId, namespace, skillSlug, version, status,
|
this(id, skillVersionId, namespace, skillSlug, version, status,
|
||||||
submittedBy, submittedByName, reviewedBy, reviewedByName,
|
submittedBy, submittedByName, reviewedBy, reviewedByName,
|
||||||
reviewComment, submittedAt, reviewedAt,
|
reviewComment, submittedAt, reviewedAt,
|
||||||
"SKILL_VERSION", null, skillVersionId, skillSlug);
|
"SKILL_VERSION", null, skillVersionId, skillSlug, null, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for saving one draft file. Text is the default encoding; base64
|
||||||
|
* transports binary-safe content.
|
||||||
|
*/
|
||||||
|
public record SaveDraftFileRequest(
|
||||||
|
@NotBlank(message = "{error.badRequest}") String path,
|
||||||
|
@NotNull(message = "{error.badRequest}") String content,
|
||||||
|
@Pattern(regexp = "(?i)(text|base64)", message = "{error.badRequest}") String encoding,
|
||||||
|
String contentType,
|
||||||
|
Integer expectedRevision
|
||||||
|
) {
|
||||||
|
|
||||||
|
public boolean isBase64() {
|
||||||
|
return "base64".equalsIgnoreCase(encoding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.domain.authoring.service.SkillDraftService;
|
||||||
|
|
||||||
|
/** Result of saving one draft file: updated draft plus file metadata. */
|
||||||
|
public record SaveDraftFileResponse(
|
||||||
|
DraftResponse draft,
|
||||||
|
DraftFileResponse file,
|
||||||
|
boolean created,
|
||||||
|
boolean revisionAdvanced
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static SaveDraftFileResponse from(SkillDraftService.SaveFileOutcome outcome) {
|
||||||
|
return new SaveDraftFileResponse(
|
||||||
|
DraftResponse.from(outcome.draft()),
|
||||||
|
DraftFileResponse.from(outcome.file()),
|
||||||
|
outcome.created(),
|
||||||
|
outcome.revisionAdvanced());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record SkillAvatarDecisionRequest(boolean approved, @NotBlank String reason) {}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record SkillAvatarReasonRequest(@NotBlank String reason) {}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
public record SkillAvatarStatusResponse(String avatarUrl, Long candidateId, String candidateStatus,
|
||||||
|
String machineStatus, String reviewStatus, String reviewReason,
|
||||||
|
String candidateUrl, String namespace, String slug,
|
||||||
|
java.time.Instant machineNextRetryAt, boolean automaticRetryExhausted) {}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record SkillAvatarSubmitRequest(@NotBlank String uploadId) {}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record SkillAvatarUploadResponse(@NotNull String uploadId, @NotNull String previewUrl, @NotNull Instant expiresAt) {}
|
||||||
|
|
@ -30,5 +30,6 @@ public record SkillDetailResponse(
|
||||||
String ownerPreviewReviewComment,
|
String ownerPreviewReviewComment,
|
||||||
String resolutionMode,
|
String resolutionMode,
|
||||||
List<SkillSuiteReferenceResponse> entryForSuites,
|
List<SkillSuiteReferenceResponse> entryForSuites,
|
||||||
PageResponse<SkillSuiteReferenceResponse> memberOfSuites
|
PageResponse<SkillSuiteReferenceResponse> memberOfSuites,
|
||||||
|
String avatarUrl
|
||||||
) {}
|
) {}
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,23 @@ public record SkillSummaryResponse(
|
||||||
* that do not opt in.
|
* that do not opt in.
|
||||||
*/
|
*/
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
List<SkillLabelDto> labels
|
List<SkillLabelDto> labels,
|
||||||
|
String avatarUrl
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
public SkillSummaryResponse(
|
||||||
|
Long id, String slug, String displayName, String summary, String visibility, String status,
|
||||||
|
Long downloadCount, Integer starCount, BigDecimal ratingAvg, Integer ratingCount,
|
||||||
|
String namespace, Instant updatedAt, String ownerId, String ownerDisplayName,
|
||||||
|
boolean canSubmitPromotion, SkillLifecycleVersionResponse headlineVersion,
|
||||||
|
SkillLifecycleVersionResponse publishedVersion, SkillLifecycleVersionResponse ownerPreviewVersion,
|
||||||
|
String resolutionMode, ComplianceSnapshotResponse complianceSnapshot, List<SkillLabelDto> labels) {
|
||||||
|
this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg,
|
||||||
|
ratingCount, namespace, updatedAt, ownerId, ownerDisplayName, canSubmitPromotion,
|
||||||
|
headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot,
|
||||||
|
labels, null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Summary without label projection.
|
* Summary without label projection.
|
||||||
*/
|
*/
|
||||||
|
|
@ -93,6 +107,6 @@ public record SkillSummaryResponse(
|
||||||
return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount,
|
return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount,
|
||||||
starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName,
|
starCount, ratingAvg, ratingCount, namespace, updatedAt, ownerId, ownerDisplayName,
|
||||||
canSubmitPromotion, headlineVersion,
|
canSubmitPromotion, headlineVersion,
|
||||||
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels);
|
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels, avatarUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record SystemAuthSettingsResponse(
|
||||||
|
boolean passwordLoginEnabled,
|
||||||
|
boolean selfRegistrationEnabled,
|
||||||
|
long version,
|
||||||
|
Instant updatedAt
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record SystemAuthSettingsUpdateRequest(
|
||||||
|
@NotNull Boolean passwordLoginEnabled,
|
||||||
|
@NotNull Boolean selfRegistrationEnabled,
|
||||||
|
@NotNull Long version
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** One persisted validation event (log line, tool call, finding, phase marker...). */
|
||||||
|
public record ValidationEventResponse(
|
||||||
|
Long id,
|
||||||
|
Long runId,
|
||||||
|
Integer seq,
|
||||||
|
String type,
|
||||||
|
String phase,
|
||||||
|
Map<String, Object> payload,
|
||||||
|
Instant createdAt
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static ValidationEventResponse from(ValidationEvent event) {
|
||||||
|
return new ValidationEventResponse(
|
||||||
|
event.getId(),
|
||||||
|
event.getRunId(),
|
||||||
|
event.getSeq(),
|
||||||
|
event.getEventType().name(),
|
||||||
|
event.getPhase(),
|
||||||
|
event.getPayload() == null ? Map.of() : event.getPayload(),
|
||||||
|
event.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.domain.authoring.FixSuggestion;
|
||||||
|
import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One validation finding. {@code suggestion} carries the machine-applicable fix
|
||||||
|
* (patches with old/new values) when one exists, for preview and confirmed apply.
|
||||||
|
*/
|
||||||
|
public record ValidationFindingResponse(
|
||||||
|
Long id,
|
||||||
|
Long runId,
|
||||||
|
String layer,
|
||||||
|
String ruleCode,
|
||||||
|
String severity,
|
||||||
|
String filePath,
|
||||||
|
String location,
|
||||||
|
String message,
|
||||||
|
FixSuggestion suggestion,
|
||||||
|
String status,
|
||||||
|
Integer appliedRevision,
|
||||||
|
Instant createdAt
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static ValidationFindingResponse from(ValidationFinding finding) {
|
||||||
|
return new ValidationFindingResponse(
|
||||||
|
finding.getId(),
|
||||||
|
finding.getRunId(),
|
||||||
|
finding.getLayer().name(),
|
||||||
|
finding.getRuleCode(),
|
||||||
|
finding.getSeverity().name(),
|
||||||
|
finding.getFilePath(),
|
||||||
|
finding.getLocation(),
|
||||||
|
finding.getMessage(),
|
||||||
|
finding.getSuggestion(),
|
||||||
|
finding.getStatus().name(),
|
||||||
|
finding.getAppliedRevision(),
|
||||||
|
finding.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package com.iflytek.skillhub.dto;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.domain.authoring.validation.ValidationRun;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Validation run state and outcome counters. */
|
||||||
|
public record ValidationRunResponse(
|
||||||
|
Long id,
|
||||||
|
Long draftId,
|
||||||
|
Integer draftRevision,
|
||||||
|
String status,
|
||||||
|
boolean cancelRequested,
|
||||||
|
boolean active,
|
||||||
|
boolean terminal,
|
||||||
|
Integer errorCount,
|
||||||
|
Integer warningCount,
|
||||||
|
String triggeredBy,
|
||||||
|
Instant startedAt,
|
||||||
|
Instant finishedAt,
|
||||||
|
Instant createdAt,
|
||||||
|
Map<String, Object> summary
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static ValidationRunResponse from(ValidationRun run) {
|
||||||
|
return from(run, run.getSummary());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ValidationRunResponse from(ValidationRun run, Map<String, Object> summary) {
|
||||||
|
return new ValidationRunResponse(
|
||||||
|
run.getId(),
|
||||||
|
run.getDraftId(),
|
||||||
|
run.getDraftRevision(),
|
||||||
|
run.getStatus().name(),
|
||||||
|
run.isCancelRequested(),
|
||||||
|
run.isActive(),
|
||||||
|
run.getStatus().isTerminal(),
|
||||||
|
run.getErrorCount(),
|
||||||
|
run.getWarningCount(),
|
||||||
|
run.getTriggeredBy(),
|
||||||
|
run.getStartedAt(),
|
||||||
|
run.getFinishedAt(),
|
||||||
|
run.getCreatedAt(),
|
||||||
|
summary == null ? Map.of() : summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package com.iflytek.skillhub.listener;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
|
||||||
|
import com.iflytek.skillhub.dto.SkillAvatarStatusResponse;
|
||||||
|
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||||
|
import com.iflytek.skillhub.service.SkillAvatarAppService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.event.TransactionPhase;
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class SkillAvatarScopeReviewListener {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SkillAvatarScopeReviewListener.class);
|
||||||
|
private final SkillAvatarAppService avatars;
|
||||||
|
|
||||||
|
public SkillAvatarScopeReviewListener(SkillAvatarAppService avatars) {
|
||||||
|
this.avatars = avatars;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async("skillhubEventExecutor")
|
||||||
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||||
|
public void onSkillPublished(SkillPublishedEvent event) {
|
||||||
|
reviewScope(event.skillId(), event.publisherId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reviewScope(Long skillId, String publisherId) {
|
||||||
|
try {
|
||||||
|
SkillAvatarStatusResponse candidate = avatars.prepareScopeReview(skillId, publisherId);
|
||||||
|
if (candidate != null && ("PENDING".equals(candidate.machineStatus())
|
||||||
|
|| "PROCESSING".equals(candidate.machineStatus())
|
||||||
|
|| "ERROR".equals(candidate.machineStatus()))) {
|
||||||
|
avatars.processMachine(candidate.candidateId(), new AuditRequestContext(null, null));
|
||||||
|
}
|
||||||
|
} catch (Exception failure) {
|
||||||
|
// Provider errors can include credential-bearing request URLs. Never log their message or stack here.
|
||||||
|
log.warn("Avatar scope review failed for skill {}, failureType={}",
|
||||||
|
skillId, failure.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.iflytek.skillhub.listener;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.service.SkillAvatarAppService;
|
||||||
|
import com.iflytek.skillhub.service.SkillAvatarUploadService.AvatarSubmittedEvent;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.event.TransactionPhase;
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class SkillAvatarSubmissionListener {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SkillAvatarSubmissionListener.class);
|
||||||
|
private final SkillAvatarAppService avatars;
|
||||||
|
public SkillAvatarSubmissionListener(SkillAvatarAppService avatars) { this.avatars = avatars; }
|
||||||
|
|
||||||
|
@Async("skillhubEventExecutor")
|
||||||
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||||
|
public void submitted(AvatarSubmittedEvent event) {
|
||||||
|
try {
|
||||||
|
avatars.processMachine(event.candidateId(), event.context());
|
||||||
|
} catch (Exception failure) {
|
||||||
|
log.warn("Avatar submission moderation failed for candidate {}, failureType={}",
|
||||||
|
event.candidateId(), failure.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,7 @@ public class SkillSuiteBundleEventListener {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void advanceBoundOperations(Long skillVersionId) {
|
private void advanceBoundOperations(Long skillVersionId) {
|
||||||
|
if (skillVersionId == null) { return; }
|
||||||
memberRepository.findBySkillVersionId(skillVersionId).stream()
|
memberRepository.findBySkillVersionId(skillVersionId).stream()
|
||||||
.map(member -> member.getOperationId())
|
.map(member -> member.getOperationId())
|
||||||
.distinct()
|
.distinct()
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,7 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
|
||||||
: require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getSkillId();
|
: require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getSkillId();
|
||||||
Skill skill = require(bundle.skillsById(), skillId, "skill.not_found");
|
Skill skill = require(bundle.skillsById(), skillId, "skill.not_found");
|
||||||
Namespace namespace = require(bundle.namespacesById(), task.getNamespaceId(), "namespace.not_found");
|
Namespace namespace = require(bundle.namespacesById(), task.getNamespaceId(), "namespace.not_found");
|
||||||
String skillVersion = task.getSkillVersion() != null
|
String skillVersion = task.getSubjectType() == ReviewSubjectType.SKILL_METADATA ? null : task.getSkillVersion() != null
|
||||||
? task.getSkillVersion()
|
? task.getSkillVersion()
|
||||||
: require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getVersion();
|
: require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getVersion();
|
||||||
UserAccount submittedBy = bundle.usersById().get(task.getSubmittedBy());
|
UserAccount submittedBy = bundle.usersById().get(task.getSubmittedBy());
|
||||||
|
|
@ -251,7 +251,7 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
|
||||||
task.getReviewedAt(),
|
task.getReviewedAt(),
|
||||||
subjectType(task), task.getSubjectId() != null ? task.getSubjectId() : skillId,
|
subjectType(task), task.getSubjectId() != null ? task.getSubjectId() : skillId,
|
||||||
task.getSubjectVersionId() != null ? task.getSubjectVersionId() : task.getSkillVersionId(),
|
task.getSubjectVersionId() != null ? task.getSubjectVersionId() : task.getSkillVersionId(),
|
||||||
skill.getSlug()
|
skill.getSlug(), task.getAvatarCandidateId(), task.isAvatarRemoval()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,8 +311,11 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
|
||||||
task.getSubmittedAt() != null ? task.getSubmittedAt().toString() : null,
|
task.getSubmittedAt() != null ? task.getSubmittedAt().toString() : null,
|
||||||
namespaceSlug, null, "SUITE", suiteSlug);
|
namespaceSlug, null, "SUITE", suiteSlug);
|
||||||
}
|
}
|
||||||
SkillVersion version = bundle.versionsById().get(task.getSkillVersionId());
|
SkillVersion version = task.getSkillVersionId() != null
|
||||||
Skill skill = version != null ? bundle.skillsById().get(version.getSkillId()) : null;
|
? bundle.versionsById().get(task.getSkillVersionId()) : null;
|
||||||
|
Long skillId = task.getSkillId() != null ? task.getSkillId()
|
||||||
|
: version != null ? version.getSkillId() : null;
|
||||||
|
Skill skill = skillId != null ? bundle.skillsById().get(skillId) : null;
|
||||||
Namespace namespace = skill != null ? bundle.namespacesById().get(skill.getNamespaceId()) : null;
|
Namespace namespace = skill != null ? bundle.namespacesById().get(skill.getNamespaceId()) : null;
|
||||||
String namespaceSlug = namespace != null ? namespace.getSlug() : null;
|
String namespaceSlug = namespace != null ? namespace.getSlug() : null;
|
||||||
String skillSlug = skill != null ? skill.getSlug() : null;
|
String skillSlug = skill != null ? skill.getSlug() : null;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.user.UserAccount;
|
||||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||||
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
|
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
|
||||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||||
|
import com.iflytek.skillhub.service.SkillAvatarUrls;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
|
@ -145,7 +146,8 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
|
||||||
toLifecycleVersion(ownerPreviewVersion),
|
toLifecycleVersion(ownerPreviewVersion),
|
||||||
projection.resolutionMode().name(),
|
projection.resolutionMode().name(),
|
||||||
null,
|
null,
|
||||||
null
|
null,
|
||||||
|
SkillAvatarUrls.current(skill, namespace != null ? namespace.getSlug() : "")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
|
||||||
COALESCE(skill.slug, suite.slug) AS subject_slug
|
COALESCE(skill.slug, suite.slug) AS subject_slug
|
||||||
FROM latest
|
FROM latest
|
||||||
LEFT JOIN skill
|
LEFT JOIN skill
|
||||||
ON latest.subject_type = 'SKILL_VERSION' AND skill.id = latest.subject_id
|
ON latest.subject_type IN ('SKILL_VERSION', 'SKILL_METADATA') AND skill.id = latest.subject_id
|
||||||
LEFT JOIN skill_suite suite
|
LEFT JOIN skill_suite suite
|
||||||
ON latest.subject_type = 'SUITE_VERSION' AND suite.id = latest.subject_id
|
ON latest.subject_type = 'SUITE_VERSION' AND suite.id = latest.subject_id
|
||||||
JOIN namespace ON namespace.id = latest.namespace_id
|
JOIN namespace ON namespace.id = latest.namespace_id
|
||||||
|
|
@ -80,23 +80,23 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
|
||||||
OR LOWER(namespace.slug) LIKE :queryPattern
|
OR LOWER(namespace.slug) LIKE :queryPattern
|
||||||
)
|
)
|
||||||
AND (:status = '' OR latest.status = :status)
|
AND (:status = '' OR latest.status = :status)
|
||||||
AND (:subjectType = '' OR latest.subject_type = :subjectType)
|
AND (:subjectType = '' OR latest.subject_type = :subjectType OR :subjectType = 'SKILL_VERSION' AND latest.subject_type = 'SKILL_METADATA')
|
||||||
ORDER BY latest.submitted_at DESC, latest.id DESC
|
ORDER BY latest.submitted_at DESC, latest.id DESC
|
||||||
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
|
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static final String MY_PROGRESS_SUMMARY_SQL = RANKED_CTE + """
|
private static final String MY_PROGRESS_SUMMARY_SQL = RANKED_CTE + """
|
||||||
SELECT COUNT(*) FILTER (WHERE (:status = '' OR latest.status = :status)
|
SELECT COUNT(*) FILTER (WHERE (:status = '' OR latest.status = :status)
|
||||||
AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS filtered_total,
|
AND (:subjectType = '' OR latest.subject_type = :subjectType OR :subjectType = 'SKILL_VERSION' AND latest.subject_type = 'SKILL_METADATA')) AS filtered_total,
|
||||||
COUNT(*) FILTER (WHERE latest.status = 'PENDING'
|
COUNT(*) FILTER (WHERE latest.status = 'PENDING'
|
||||||
AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS pending_count,
|
AND (:subjectType = '' OR latest.subject_type = :subjectType OR :subjectType = 'SKILL_VERSION' AND latest.subject_type = 'SKILL_METADATA')) AS pending_count,
|
||||||
COUNT(*) FILTER (WHERE latest.status = 'APPROVED'
|
COUNT(*) FILTER (WHERE latest.status = 'APPROVED'
|
||||||
AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS approved_count,
|
AND (:subjectType = '' OR latest.subject_type = :subjectType OR :subjectType = 'SKILL_VERSION' AND latest.subject_type = 'SKILL_METADATA')) AS approved_count,
|
||||||
COUNT(*) FILTER (WHERE latest.status = 'REJECTED'
|
COUNT(*) FILTER (WHERE latest.status = 'REJECTED'
|
||||||
AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS rejected_count
|
AND (:subjectType = '' OR latest.subject_type = :subjectType OR :subjectType = 'SKILL_VERSION' AND latest.subject_type = 'SKILL_METADATA')) AS rejected_count
|
||||||
FROM latest
|
FROM latest
|
||||||
LEFT JOIN skill
|
LEFT JOIN skill
|
||||||
ON latest.subject_type = 'SKILL_VERSION' AND skill.id = latest.subject_id
|
ON latest.subject_type IN ('SKILL_VERSION', 'SKILL_METADATA') AND skill.id = latest.subject_id
|
||||||
LEFT JOIN skill_suite suite
|
LEFT JOIN skill_suite suite
|
||||||
ON latest.subject_type = 'SUITE_VERSION' AND suite.id = latest.subject_id
|
ON latest.subject_type = 'SUITE_VERSION' AND suite.id = latest.subject_id
|
||||||
JOIN namespace ON namespace.id = latest.namespace_id
|
JOIN namespace ON namespace.id = latest.namespace_id
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
|
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
|
||||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||||
|
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||||
|
|
@ -48,6 +49,7 @@ public class AdminNamespaceAppService {
|
||||||
private final NamespaceGovernanceService namespaceGovernanceService;
|
private final NamespaceGovernanceService namespaceGovernanceService;
|
||||||
private final NamespaceMemberService namespaceMemberService;
|
private final NamespaceMemberService namespaceMemberService;
|
||||||
private final NamespaceMemberRepository namespaceMemberRepository;
|
private final NamespaceMemberRepository namespaceMemberRepository;
|
||||||
|
private final NamespaceRepository namespaceRepository;
|
||||||
private final NamespaceMemberCandidateService namespaceMemberCandidateService;
|
private final NamespaceMemberCandidateService namespaceMemberCandidateService;
|
||||||
private final NamespaceAccessPolicy namespaceAccessPolicy;
|
private final NamespaceAccessPolicy namespaceAccessPolicy;
|
||||||
private final UserAccountRepository userAccountRepository;
|
private final UserAccountRepository userAccountRepository;
|
||||||
|
|
@ -57,6 +59,7 @@ public class AdminNamespaceAppService {
|
||||||
NamespaceGovernanceService namespaceGovernanceService,
|
NamespaceGovernanceService namespaceGovernanceService,
|
||||||
NamespaceMemberService namespaceMemberService,
|
NamespaceMemberService namespaceMemberService,
|
||||||
NamespaceMemberRepository namespaceMemberRepository,
|
NamespaceMemberRepository namespaceMemberRepository,
|
||||||
|
NamespaceRepository namespaceRepository,
|
||||||
NamespaceMemberCandidateService namespaceMemberCandidateService,
|
NamespaceMemberCandidateService namespaceMemberCandidateService,
|
||||||
NamespaceAccessPolicy namespaceAccessPolicy,
|
NamespaceAccessPolicy namespaceAccessPolicy,
|
||||||
UserAccountRepository userAccountRepository) {
|
UserAccountRepository userAccountRepository) {
|
||||||
|
|
@ -65,6 +68,7 @@ public class AdminNamespaceAppService {
|
||||||
this.namespaceGovernanceService = namespaceGovernanceService;
|
this.namespaceGovernanceService = namespaceGovernanceService;
|
||||||
this.namespaceMemberService = namespaceMemberService;
|
this.namespaceMemberService = namespaceMemberService;
|
||||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||||
|
this.namespaceRepository = namespaceRepository;
|
||||||
this.namespaceMemberCandidateService = namespaceMemberCandidateService;
|
this.namespaceMemberCandidateService = namespaceMemberCandidateService;
|
||||||
this.namespaceAccessPolicy = namespaceAccessPolicy;
|
this.namespaceAccessPolicy = namespaceAccessPolicy;
|
||||||
this.userAccountRepository = userAccountRepository;
|
this.userAccountRepository = userAccountRepository;
|
||||||
|
|
@ -139,7 +143,7 @@ public class AdminNamespaceAppService {
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MemberResponse addMember(String slug, MemberRequest request, String actorUserId) {
|
public MemberResponse addMember(String slug, MemberRequest request, String actorUserId) {
|
||||||
Namespace namespace = requireMutableTeamNamespace(slug);
|
Namespace namespace = lockedMutableTeamNamespace(slug, List.of(request.userId()));
|
||||||
if (request.role() == NamespaceRole.OWNER) {
|
if (request.role() == NamespaceRole.OWNER) {
|
||||||
throw new DomainBadRequestException("error.namespace.member.owner.assignDirect");
|
throw new DomainBadRequestException("error.namespace.member.owner.assignDirect");
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +156,7 @@ public class AdminNamespaceAppService {
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public BatchMemberResponse batchAddMembers(String slug, BatchMemberRequest request, String actorUserId) {
|
public BatchMemberResponse batchAddMembers(String slug, BatchMemberRequest request, String actorUserId) {
|
||||||
Namespace namespace = requireMutableTeamNamespace(slug);
|
Namespace namespace = lockedMutableTeamNamespace(slug, request.members().stream().map(MemberRequest::userId).toList());
|
||||||
List<BatchMemberResult> results = new ArrayList<>();
|
List<BatchMemberResult> results = new ArrayList<>();
|
||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
int failureCount = 0;
|
int failureCount = 0;
|
||||||
|
|
@ -178,7 +182,7 @@ public class AdminNamespaceAppService {
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MemberResponse updateMemberRole(String slug, String userId, UpdateMemberRoleRequest request, String actorUserId) {
|
public MemberResponse updateMemberRole(String slug, String userId, UpdateMemberRoleRequest request, String actorUserId) {
|
||||||
Namespace namespace = requireMutableTeamNamespace(slug);
|
Namespace namespace = lockedMutableTeamNamespace(slug, List.of(userId));
|
||||||
if (request.role() == NamespaceRole.OWNER) {
|
if (request.role() == NamespaceRole.OWNER) {
|
||||||
throw new DomainBadRequestException("error.namespace.member.owner.setDirect");
|
throw new DomainBadRequestException("error.namespace.member.owner.setDirect");
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +198,7 @@ public class AdminNamespaceAppService {
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MessageResponse removeMember(String slug, String userId, String actorUserId) {
|
public MessageResponse removeMember(String slug, String userId, String actorUserId) {
|
||||||
Namespace namespace = requireMutableTeamNamespace(slug);
|
Namespace namespace = lockedMutableTeamNamespace(slug, List.of(userId));
|
||||||
NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), userId)
|
NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), userId)
|
||||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound"));
|
.orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound"));
|
||||||
if (member.getRole() == NamespaceRole.OWNER) {
|
if (member.getRole() == NamespaceRole.OWNER) {
|
||||||
|
|
@ -206,7 +210,12 @@ public class AdminNamespaceAppService {
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MessageResponse transferOwnership(String slug, String newOwnerId, String actorUserId) {
|
public MessageResponse transferOwnership(String slug, String newOwnerId, String actorUserId) {
|
||||||
Namespace namespace = requireMutableTeamNamespace(slug);
|
Long namespaceId = namespaceRepository.findIdBySlug(slug)
|
||||||
|
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
|
||||||
|
String oldOwnerId = namespaceMemberRepository.findByNamespaceIdAndRoleIn(namespaceId, List.of(NamespaceRole.OWNER))
|
||||||
|
.stream().findFirst().map(NamespaceMember::getUserId)
|
||||||
|
.orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.current.notFound"));
|
||||||
|
Namespace namespace = lockedMutableTeamNamespace(slug, List.of(oldOwnerId, newOwnerId));
|
||||||
NamespaceMember currentOwner = namespaceMemberRepository.findByNamespaceIdAndRoleIn(namespace.getId(), List.of(NamespaceRole.OWNER))
|
NamespaceMember currentOwner = namespaceMemberRepository.findByNamespaceIdAndRoleIn(namespace.getId(), List.of(NamespaceRole.OWNER))
|
||||||
.stream()
|
.stream()
|
||||||
.findFirst()
|
.findFirst()
|
||||||
|
|
@ -295,15 +304,19 @@ public class AdminNamespaceAppService {
|
||||||
.collect(Collectors.toMap(NamespaceMember::getNamespaceId, NamespaceMember::getRole));
|
.collect(Collectors.toMap(NamespaceMember::getNamespaceId, NamespaceMember::getRole));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Namespace requireMutableTeamNamespace(String slug) {
|
private Namespace lockedMutableTeamNamespace(String slug, List<String> userIds) {
|
||||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
Long namespaceId = namespaceRepository.findIdBySlug(slug)
|
||||||
if (namespaceAccessPolicy.isImmutable(namespace)) {
|
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
|
||||||
throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug());
|
userIds.stream().distinct().sorted().forEach(userAccountRepository::findLockedById);
|
||||||
|
Namespace locked = namespaceRepository.findLockedById(namespaceId)
|
||||||
|
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
|
||||||
|
if (namespaceAccessPolicy.isImmutable(locked)) {
|
||||||
|
throw new DomainBadRequestException("error.namespace.system.immutable", slug);
|
||||||
}
|
}
|
||||||
if (!namespaceAccessPolicy.canManageMembers(namespace)) {
|
if (!namespaceAccessPolicy.canManageMembers(locked)) {
|
||||||
throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug());
|
throw new DomainBadRequestException("error.namespace.readonly", slug);
|
||||||
}
|
}
|
||||||
return namespace;
|
return locked;
|
||||||
}
|
}
|
||||||
|
|
||||||
private NamespaceStatus parseStatus(String status) {
|
private NamespaceStatus parseStatus(String status) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
package com.iflytek.skillhub.service;
|
||||||
|
|
||||||
|
import com.aliyun.green20220302.Client;
|
||||||
|
import com.aliyun.green20220302.models.DescribeUploadTokenResponse;
|
||||||
|
import com.aliyun.green20220302.models.DescribeUploadTokenResponseBody.DescribeUploadTokenResponseBodyData;
|
||||||
|
import com.aliyun.green20220302.models.ImageModerationRequest;
|
||||||
|
import com.aliyun.green20220302.models.ImageModerationResponse;
|
||||||
|
import com.aliyun.green20220302.models.ImageModerationResponseBody;
|
||||||
|
import com.aliyun.oss.OSS;
|
||||||
|
import com.aliyun.oss.OSSClientBuilder;
|
||||||
|
import com.aliyun.oss.ClientBuilderConfiguration;
|
||||||
|
import com.aliyun.oss.model.PutObjectRequest;
|
||||||
|
import com.aliyun.teaopenapi.models.Config;
|
||||||
|
import com.aliyun.teautil.models.RuntimeOptions;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** Uses Alibaba Cloud's short-lived private upload token; never exposes a public candidate URL. */
|
||||||
|
@Component
|
||||||
|
public class AliyunAvatarModerationProvider implements AvatarModerationProvider {
|
||||||
|
private static final String ENDPOINT = "green-cip.cn-shanghai.aliyuncs.com";
|
||||||
|
private static final String SERVICE_ID = "profilePhotoCheck";
|
||||||
|
private final String accessKeyId;
|
||||||
|
private final String accessKeySecret;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
|
public AliyunAvatarModerationProvider(
|
||||||
|
@Value("${skillhub.avatar.moderation.aliyun.access-key-id:}") String accessKeyId,
|
||||||
|
@Value("${skillhub.avatar.moderation.aliyun.access-key-secret:}") String accessKeySecret,
|
||||||
|
ObjectMapper mapper) {
|
||||||
|
this.accessKeyId = accessKeyId;
|
||||||
|
this.accessKeySecret = accessKeySecret;
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public Context context() {
|
||||||
|
return new Context(SERVICE_ID, "2022-03-02",
|
||||||
|
"aliyun-profile-photo/1:none,low=PASS;medium,high=BLOCK;other=ERROR", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public String code() { return "aliyun"; }
|
||||||
|
|
||||||
|
@Override public boolean configured() { return !accessKeyId.isBlank() && !accessKeySecret.isBlank(); }
|
||||||
|
|
||||||
|
@Override public Verdict moderate(byte[] normalizedPng) {
|
||||||
|
if (!configured()) {
|
||||||
|
return Verdict.ERROR;
|
||||||
|
}
|
||||||
|
OSS oss = null;
|
||||||
|
try {
|
||||||
|
Config config = new Config();
|
||||||
|
config.setAccessKeyId(accessKeyId);
|
||||||
|
config.setAccessKeySecret(accessKeySecret);
|
||||||
|
config.setEndpoint(ENDPOINT);
|
||||||
|
Client client = new Client(config);
|
||||||
|
RuntimeOptions runtime = new RuntimeOptions().setConnectTimeout(3000).setReadTimeout(8000)
|
||||||
|
.setAutoretry(false);
|
||||||
|
DescribeUploadTokenResponse tokenResponse = client.describeUploadTokenWithOptions(runtime);
|
||||||
|
if (tokenResponse == null || tokenResponse.getBody() == null
|
||||||
|
|| !Objects.equals(tokenResponse.getBody().getCode(), 200)) {
|
||||||
|
return Verdict.ERROR;
|
||||||
|
}
|
||||||
|
DescribeUploadTokenResponseBodyData token = tokenResponse.getBody().getData();
|
||||||
|
if (token == null || token.getBucketName() == null || token.getFileNamePrefix() == null
|
||||||
|
|| token.getOssInternetEndPoint() == null || token.getSecurityToken() == null) {
|
||||||
|
return Verdict.ERROR;
|
||||||
|
}
|
||||||
|
ClientBuilderConfiguration ossConfig = new ClientBuilderConfiguration();
|
||||||
|
ossConfig.setConnectionTimeout(3000);
|
||||||
|
ossConfig.setSocketTimeout(8000);
|
||||||
|
oss = new OSSClientBuilder().build(token.getOssInternetEndPoint(), token.getAccessKeyId(),
|
||||||
|
token.getAccessKeySecret(), token.getSecurityToken(), ossConfig);
|
||||||
|
String objectName = token.getFileNamePrefix() + UUID.randomUUID() + ".png";
|
||||||
|
oss.putObject(new PutObjectRequest(token.getBucketName(), objectName,
|
||||||
|
new ByteArrayInputStream(normalizedPng)));
|
||||||
|
|
||||||
|
ImageModerationRequest request = new ImageModerationRequest();
|
||||||
|
request.setService(SERVICE_ID);
|
||||||
|
request.setServiceParameters(mapper.writeValueAsString(Map.of(
|
||||||
|
"ossBucketName", token.getBucketName(),
|
||||||
|
"ossObjectName", objectName,
|
||||||
|
"dataId", UUID.randomUUID().toString())));
|
||||||
|
ImageModerationResponse response = client.imageModerationWithOptions(request, runtime);
|
||||||
|
return classify(response);
|
||||||
|
} catch (Exception failure) {
|
||||||
|
// SDK exception text may include credential or temporary upload details.
|
||||||
|
return Verdict.ERROR;
|
||||||
|
} finally {
|
||||||
|
if (oss != null) {
|
||||||
|
oss.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Verdict classify(ImageModerationResponse response) {
|
||||||
|
if (response == null || !Objects.equals(response.getStatusCode(), 200)) {
|
||||||
|
return Verdict.ERROR;
|
||||||
|
}
|
||||||
|
ImageModerationResponseBody body = response.getBody();
|
||||||
|
if (body == null || !Objects.equals(body.getCode(), 200) || body.getData() == null
|
||||||
|
|| body.getData().getResult() == null || body.getData().getResult().isEmpty()) {
|
||||||
|
return Verdict.ERROR;
|
||||||
|
}
|
||||||
|
if (body.getData().getRiskLevel() == null) {
|
||||||
|
return Verdict.ERROR;
|
||||||
|
}
|
||||||
|
// Official guidance treats medium as requiring human review. BLOCK keeps the image
|
||||||
|
// private and lets the owner request the platform's exceptional manual review.
|
||||||
|
return switch (body.getData().getRiskLevel()) {
|
||||||
|
case "none", "low" -> Verdict.PASS;
|
||||||
|
case "medium", "high" -> Verdict.BLOCK;
|
||||||
|
default -> Verdict.ERROR;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package com.iflytek.skillhub.service;
|
||||||
|
|
||||||
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
|
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
|
||||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||||
|
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
|
||||||
import com.iflytek.skillhub.dto.AuthMeResponse;
|
import com.iflytek.skillhub.dto.AuthMeResponse;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
|
@ -13,15 +14,19 @@ import org.springframework.stereotype.Service;
|
||||||
public class AuthMeResponseAssembler {
|
public class AuthMeResponseAssembler {
|
||||||
|
|
||||||
private final LocalCredentialRepository localCredentialRepository;
|
private final LocalCredentialRepository localCredentialRepository;
|
||||||
|
private final LocalAuthSettingsService authSettings;
|
||||||
|
|
||||||
public AuthMeResponseAssembler(LocalCredentialRepository localCredentialRepository) {
|
public AuthMeResponseAssembler(LocalCredentialRepository localCredentialRepository,
|
||||||
|
LocalAuthSettingsService authSettings) {
|
||||||
this.localCredentialRepository = localCredentialRepository;
|
this.localCredentialRepository = localCredentialRepository;
|
||||||
|
this.authSettings = authSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AuthMeResponse from(PlatformPrincipal principal) {
|
public AuthMeResponse from(PlatformPrincipal principal) {
|
||||||
return AuthMeResponse.from(
|
return AuthMeResponse.from(
|
||||||
principal,
|
principal,
|
||||||
localCredentialRepository.existsByUserId(principal.userId())
|
authSettings.current().passwordLoginEnabled()
|
||||||
|
&& localCredentialRepository.existsByUserId(principal.userId())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.iflytek.skillhub.service;
|
package com.iflytek.skillhub.service;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.auth.settings.LocalAuthSettingsService;
|
||||||
import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator;
|
import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator;
|
||||||
import com.iflytek.skillhub.auth.direct.DirectAuthProvider;
|
import com.iflytek.skillhub.auth.direct.DirectAuthProvider;
|
||||||
import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport;
|
import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport;
|
||||||
|
|
@ -28,17 +29,20 @@ public class AuthMethodCatalog {
|
||||||
private final AuthSessionBootstrapProperties sessionBootstrapProperties;
|
private final AuthSessionBootstrapProperties sessionBootstrapProperties;
|
||||||
private final List<DirectAuthProvider> directAuthProviders;
|
private final List<DirectAuthProvider> directAuthProviders;
|
||||||
private final List<PassiveSessionAuthenticator> passiveSessionAuthenticators;
|
private final List<PassiveSessionAuthenticator> passiveSessionAuthenticators;
|
||||||
|
private final LocalAuthSettingsService authSettings;
|
||||||
|
|
||||||
public AuthMethodCatalog(OAuth2ClientProperties oAuth2ClientProperties,
|
public AuthMethodCatalog(OAuth2ClientProperties oAuth2ClientProperties,
|
||||||
DirectAuthProperties directAuthProperties,
|
DirectAuthProperties directAuthProperties,
|
||||||
AuthSessionBootstrapProperties sessionBootstrapProperties,
|
AuthSessionBootstrapProperties sessionBootstrapProperties,
|
||||||
List<DirectAuthProvider> directAuthProviders,
|
List<DirectAuthProvider> directAuthProviders,
|
||||||
List<PassiveSessionAuthenticator> passiveSessionAuthenticators) {
|
List<PassiveSessionAuthenticator> passiveSessionAuthenticators,
|
||||||
|
LocalAuthSettingsService authSettings) {
|
||||||
this.oAuth2ClientProperties = oAuth2ClientProperties;
|
this.oAuth2ClientProperties = oAuth2ClientProperties;
|
||||||
this.directAuthProperties = directAuthProperties;
|
this.directAuthProperties = directAuthProperties;
|
||||||
this.sessionBootstrapProperties = sessionBootstrapProperties;
|
this.sessionBootstrapProperties = sessionBootstrapProperties;
|
||||||
this.directAuthProviders = directAuthProviders;
|
this.directAuthProviders = directAuthProviders;
|
||||||
this.passiveSessionAuthenticators = passiveSessionAuthenticators;
|
this.passiveSessionAuthenticators = passiveSessionAuthenticators;
|
||||||
|
this.authSettings = authSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AuthProviderResponse> listOAuthProviders(String returnTo) {
|
public List<AuthProviderResponse> listOAuthProviders(String returnTo) {
|
||||||
|
|
@ -70,14 +74,17 @@ public class AuthMethodCatalog {
|
||||||
public List<AuthMethodResponse> listMethods(String returnTo) {
|
public List<AuthMethodResponse> listMethods(String returnTo) {
|
||||||
String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo);
|
String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo);
|
||||||
List<AuthMethodResponse> methods = new ArrayList<>();
|
List<AuthMethodResponse> methods = new ArrayList<>();
|
||||||
|
boolean passwordEnabled = authSettings.current().passwordLoginEnabled();
|
||||||
|
|
||||||
methods.add(new AuthMethodResponse(
|
if (passwordEnabled) {
|
||||||
"local-password",
|
methods.add(new AuthMethodResponse(
|
||||||
"PASSWORD",
|
"local-password",
|
||||||
"local",
|
"PASSWORD",
|
||||||
"Local Account",
|
"local",
|
||||||
"/api/v1/auth/local/login"
|
"Local Account",
|
||||||
));
|
"/api/v1/auth/local/login"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
oAuth2ClientProperties.getRegistration().entrySet().stream()
|
oAuth2ClientProperties.getRegistration().entrySet().stream()
|
||||||
.filter(entry -> isValidOAuthProvider(entry.getValue()))
|
.filter(entry -> isValidOAuthProvider(entry.getValue()))
|
||||||
|
|
@ -94,6 +101,8 @@ public class AuthMethodCatalog {
|
||||||
|
|
||||||
if (directAuthProperties.isEnabled()) {
|
if (directAuthProperties.isEnabled()) {
|
||||||
directAuthProviders.stream()
|
directAuthProviders.stream()
|
||||||
|
.filter(provider -> !"local".equals(provider.providerCode())
|
||||||
|
|| passwordEnabled)
|
||||||
.sorted(Comparator.comparing(DirectAuthProvider::providerCode))
|
.sorted(Comparator.comparing(DirectAuthProvider::providerCode))
|
||||||
.forEach(provider -> methods.add(new AuthMethodResponse(
|
.forEach(provider -> methods.add(new AuthMethodResponse(
|
||||||
"direct-" + provider.providerCode(),
|
"direct-" + provider.providerCode(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
package com.iflytek.skillhub.service;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AvatarModerationGateway {
|
||||||
|
private final String selectedProvider;
|
||||||
|
private final List<AvatarModerationProvider> providers;
|
||||||
|
|
||||||
|
public AvatarModerationGateway(@Value("${skillhub.avatar.moderation.provider:}") String selectedProvider,
|
||||||
|
List<AvatarModerationProvider> providers) {
|
||||||
|
this.selectedProvider = selectedProvider;
|
||||||
|
this.providers = providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String requireConfiguredProvider() {
|
||||||
|
return providers.stream()
|
||||||
|
.filter(provider -> provider.code().equals(selectedProvider) && provider.configured())
|
||||||
|
.findFirst()
|
||||||
|
.map(AvatarModerationProvider::code)
|
||||||
|
.orElseThrow(() -> new DomainBadRequestException("error.skill.avatar.providerUnavailable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public AvatarModerationProvider.Context context(String providerCode) {
|
||||||
|
return providers.stream()
|
||||||
|
.filter(provider -> provider.code().equals(providerCode) && providerCode.equals(selectedProvider)
|
||||||
|
&& provider.configured())
|
||||||
|
.findFirst().map(AvatarModerationProvider::context)
|
||||||
|
.orElse(AvatarModerationProvider.Context.UNKNOWN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AvatarModerationProvider.Verdict moderate(String providerCode, byte[] image) {
|
||||||
|
if (!providerCode.equals(selectedProvider)) {
|
||||||
|
return AvatarModerationProvider.Verdict.ERROR;
|
||||||
|
}
|
||||||
|
return providers.stream()
|
||||||
|
.filter(provider -> provider.code().equals(providerCode) && provider.configured())
|
||||||
|
.findFirst()
|
||||||
|
.map(provider -> provider.moderate(image))
|
||||||
|
.orElse(AvatarModerationProvider.Verdict.ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.iflytek.skillhub.service;
|
||||||
|
|
||||||
|
/** A deployment-selected image reviewer; implementations must never log credentials or raw URLs. */
|
||||||
|
public interface AvatarModerationProvider {
|
||||||
|
enum Verdict { PASS, BLOCK, ERROR }
|
||||||
|
|
||||||
|
/** API/policy evidence is not a claim that the supplier's underlying model is immutable. */
|
||||||
|
record Context(String serviceId, String apiVersion, String policyRevision, String modelVersion) {
|
||||||
|
public static final Context UNKNOWN = new Context("UNKNOWN", "UNKNOWN", "UNKNOWN", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
default Context context() { return Context.UNKNOWN; }
|
||||||
|
|
||||||
|
String code();
|
||||||
|
boolean configured();
|
||||||
|
Verdict moderate(byte[] normalizedPng);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
package com.iflytek.skillhub.service;
|
||||||
|
|
||||||
|
import com.iflytek.skillhub.auth.settings.SystemSetting;
|
||||||
|
import com.iflytek.skillhub.auth.settings.SystemSettingRepository;
|
||||||
|
import com.iflytek.skillhub.domain.audit.AuditDetail;
|
||||||
|
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||||
|
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
|
||||||
|
import com.iflytek.skillhub.dto.AvatarReviewSettingsResponse;
|
||||||
|
import com.iflytek.skillhub.dto.AvatarReviewSettingsUpdateRequest;
|
||||||
|
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AvatarReviewSettingsService {
|
||||||
|
public static final String SETTING_KEY = "skill.avatar-review";
|
||||||
|
|
||||||
|
private final SystemSettingRepository settings;
|
||||||
|
private final AvatarModerationGateway gateway;
|
||||||
|
private final AuditLogService audit;
|
||||||
|
private final RequestIdAccessor requestIds;
|
||||||
|
|
||||||
|
public AvatarReviewSettingsService(SystemSettingRepository settings, AvatarModerationGateway gateway,
|
||||||
|
AuditLogService audit, RequestIdAccessor requestIds) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.gateway = gateway;
|
||||||
|
this.audit = audit;
|
||||||
|
this.requestIds = requestIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AvatarReviewSettingsResponse current() {
|
||||||
|
SystemSetting setting = load();
|
||||||
|
return new AvatarReviewSettingsResponse(
|
||||||
|
Boolean.TRUE.equals(setting.getValue().get("machineEnabled")),
|
||||||
|
!Boolean.FALSE.equals(setting.getValue().get("manualEnabled")),
|
||||||
|
setting.getVersion(), setting.getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public AvatarReviewSettingsResponse update(AvatarReviewSettingsUpdateRequest request, String actor,
|
||||||
|
AuditRequestContext context) {
|
||||||
|
if (request.machineEnabled()) {
|
||||||
|
gateway.requireConfiguredProvider();
|
||||||
|
}
|
||||||
|
SystemSetting setting = load();
|
||||||
|
if (setting.getVersion() != request.version()) {
|
||||||
|
throw new DomainConflictException("error.system.settings.version.conflict");
|
||||||
|
}
|
||||||
|
Map<String, Object> previous = setting.getValue();
|
||||||
|
setting.update(Map.of("machineEnabled", request.machineEnabled(),
|
||||||
|
"manualEnabled", request.manualEnabled()), actor);
|
||||||
|
settings.saveAndFlush(setting);
|
||||||
|
audit.record(actor, "SKILL_AVATAR_SETTINGS_UPDATE", "SYSTEM_SETTING", setting.getId(),
|
||||||
|
requestIds.current(), context.clientIp(), context.userAgent(),
|
||||||
|
AuditDetail.builder().put("previous", previous).put("current", setting.getValue()).build());
|
||||||
|
return current();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SystemSetting load() {
|
||||||
|
return settings.findBySettingKey(SETTING_KEY)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Missing required skill.avatar-review setting"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -84,8 +84,7 @@ public class NamespacePortalCommandAppService {
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MessageResponse deleteNamespace(String slug, String userId) {
|
public MessageResponse deleteNamespace(String slug, String userId) {
|
||||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
namespaceService.deleteNamespace(namespaceId(slug), userId);
|
||||||
namespaceService.deleteNamespace(namespace.getId(), userId);
|
|
||||||
return new MessageResponse("Namespace deleted successfully");
|
return new MessageResponse("Namespace deleted successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,9 +146,8 @@ public class NamespacePortalCommandAppService {
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MemberResponse addMember(String slug, String memberUserId, com.iflytek.skillhub.domain.namespace.NamespaceRole role, String operatorUserId) {
|
public MemberResponse addMember(String slug, String memberUserId, com.iflytek.skillhub.domain.namespace.NamespaceRole role, String operatorUserId) {
|
||||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
|
||||||
NamespaceMember member = namespaceMemberService.addMember(
|
NamespaceMember member = namespaceMemberService.addMember(
|
||||||
namespace.getId(),
|
namespaceId(slug),
|
||||||
memberUserId,
|
memberUserId,
|
||||||
role,
|
role,
|
||||||
operatorUserId
|
operatorUserId
|
||||||
|
|
@ -161,8 +159,7 @@ public class NamespacePortalCommandAppService {
|
||||||
// Intentionally not @Transactional: each addMember runs in its own transaction
|
// Intentionally not @Transactional: each addMember runs in its own transaction
|
||||||
// so partial success is possible (some members added even if others fail).
|
// so partial success is possible (some members added even if others fail).
|
||||||
public BatchMemberResponse batchAddMembers(String slug, List<MemberRequest> members, String operatorUserId) {
|
public BatchMemberResponse batchAddMembers(String slug, List<MemberRequest> members, String operatorUserId) {
|
||||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
Long namespaceId = namespaceId(slug);
|
||||||
Long namespaceId = namespace.getId();
|
|
||||||
|
|
||||||
List<BatchMemberResult> results = new ArrayList<>();
|
List<BatchMemberResult> results = new ArrayList<>();
|
||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
|
|
@ -195,8 +192,7 @@ public class NamespacePortalCommandAppService {
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MessageResponse removeMember(String slug, String memberUserId, String operatorUserId) {
|
public MessageResponse removeMember(String slug, String memberUserId, String operatorUserId) {
|
||||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
namespaceMemberService.removeMember(namespaceId(slug), memberUserId, operatorUserId);
|
||||||
namespaceMemberService.removeMember(namespace.getId(), memberUserId, operatorUserId);
|
|
||||||
return new MessageResponse("Member removed successfully");
|
return new MessageResponse("Member removed successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,9 +201,8 @@ public class NamespacePortalCommandAppService {
|
||||||
String userId,
|
String userId,
|
||||||
UpdateMemberRoleRequest request,
|
UpdateMemberRoleRequest request,
|
||||||
String operatorUserId) {
|
String operatorUserId) {
|
||||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
|
||||||
NamespaceMember member = namespaceMemberService.updateMemberRole(
|
NamespaceMember member = namespaceMemberService.updateMemberRole(
|
||||||
namespace.getId(),
|
namespaceId(slug),
|
||||||
userId,
|
userId,
|
||||||
request.role(),
|
request.role(),
|
||||||
operatorUserId
|
operatorUserId
|
||||||
|
|
@ -217,8 +212,7 @@ public class NamespacePortalCommandAppService {
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MessageResponse transferOwnership(String slug, String newOwnerId, String currentOwnerId) {
|
public MessageResponse transferOwnership(String slug, String newOwnerId, String currentOwnerId) {
|
||||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
namespaceMemberService.transferOwnership(namespaceId(slug), currentOwnerId, newOwnerId);
|
||||||
namespaceMemberService.transferOwnership(namespace.getId(), currentOwnerId, newOwnerId);
|
|
||||||
return new MessageResponse("Ownership transferred successfully");
|
return new MessageResponse("Ownership transferred successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,4 +220,10 @@ public class NamespacePortalCommandAppService {
|
||||||
return principal.platformRoles().contains("SKILL_ADMIN")
|
return principal.platformRoles().contains("SKILL_ADMIN")
|
||||||
|| principal.platformRoles().contains("SUPER_ADMIN");
|
|| principal.platformRoles().contains("SUPER_ADMIN");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Long namespaceId(String slug) {
|
||||||
|
return namespaceRepository.findIdBySlug(slug)
|
||||||
|
.orElseThrow(() -> new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException(
|
||||||
|
"error.namespace.slug.notFound", slug));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,9 @@ public class ReviewPortalAppService {
|
||||||
reviewTaskId, suiteContext(userId, userNsRoles, auditContext));
|
reviewTaskId, suiteContext(userId, userNsRoles, auditContext));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reviewService.withdrawReview(task.getSkillVersionId(), userId);
|
if (task.getSubjectType() == ReviewSubjectType.SKILL_METADATA) {
|
||||||
|
reviewService.withdrawMetadata(task, userId);
|
||||||
|
} else { reviewService.withdrawReview(task.getSkillVersionId(), userId); }
|
||||||
recordAudit(
|
recordAudit(
|
||||||
"REVIEW_WITHDRAW",
|
"REVIEW_WITHDRAW",
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -272,10 +274,10 @@ public class ReviewPortalAppService {
|
||||||
throw new DomainForbiddenException("review.no_permission");
|
throw new DomainForbiddenException("review.no_permission");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ReviewTask> attempts = anchor.getSubjectType() == ReviewSubjectType.SUITE_VERSION
|
List<ReviewTask> attempts = anchor.getSubjectType() != null && anchor.getSubjectType() != ReviewSubjectType.SKILL_VERSION
|
||||||
? reviewTaskRepository
|
? reviewTaskRepository
|
||||||
.findBySubmittedByAndSubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc(
|
.findBySubmittedByAndSubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc(
|
||||||
userId, ReviewSubjectType.SUITE_VERSION,
|
userId, anchor.getSubjectType(),
|
||||||
anchor.getSubjectId(), anchor.getSubjectVersion())
|
anchor.getSubjectId(), anchor.getSubjectVersion())
|
||||||
: reviewTaskRepository
|
: reviewTaskRepository
|
||||||
.findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
.findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||||
|
|
@ -301,10 +303,10 @@ public class ReviewPortalAppService {
|
||||||
throw new DomainForbiddenException("review.no_permission");
|
throw new DomainForbiddenException("review.no_permission");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ReviewTask> attempts = anchor.getSubjectType() == ReviewSubjectType.SUITE_VERSION
|
List<ReviewTask> attempts = anchor.getSubjectType() != null && anchor.getSubjectType() != ReviewSubjectType.SKILL_VERSION
|
||||||
? reviewTaskRepository
|
? reviewTaskRepository
|
||||||
.findBySubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc(
|
.findBySubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc(
|
||||||
ReviewSubjectType.SUITE_VERSION,
|
anchor.getSubjectType(),
|
||||||
anchor.getSubjectId(), anchor.getSubjectVersion())
|
anchor.getSubjectId(), anchor.getSubjectVersion())
|
||||||
: reviewTaskRepository
|
: reviewTaskRepository
|
||||||
.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue