diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..3558463f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,49 @@ +# 统一文本文件行尾为 LF,避免 Windows 编辑器把 LF 变 CRLF 污染 diff / git blame +* text=auto eol=lf + +# 源码类:显式标记,拒绝 autocrlf +*.java text eol=lf +*.kt text eol=lf +*.groovy text eol=lf +*.xml text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.json text eol=lf +*.properties text eol=lf +*.md text eol=lf +*.sql text eol=lf +*.sh text eol=lf +*.py text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.vue text eol=lf +*.html text eol=lf +*.css text eol=lf +*.scss text eol=lf +Jenkinsfile text eol=lf +Dockerfile text eol=lf + +# Windows 专用脚本保持 CRLF +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# 二进制类:禁止任何转换 +*.jar binary +*.class binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.gz binary +*.so binary +*.dll binary +*.exe binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary diff --git a/Jenkinsfile b/Jenkinsfile index 768d0746..087c0c64 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -22,6 +22,8 @@ pipeline { // 镜像仓库配置(Infra 服务器内网地址,Prod 服务器可通过内网拉取) REGISTRY = '172.17.16.7:5000' + REGISTRY_HOST = '172.17.16.7' + REGISTRY_CONTAINER = 'registry' DEPS_IMAGE = "${REGISTRY}/aiot-deps:latest" // 服务配置 @@ -36,6 +38,13 @@ pipeline { STAGING_DEPLOY_HOST = '172.17.16.7' STAGING_DEPLOY_PATH = '/opt/aiot-platform-cloud' + // 磁盘守护阈值(%):低于 MIN 直接 fail;低于 WARN 仅告警 + DISK_FREE_MIN_PCT = '5' + DISK_FREE_WARN_PCT = '10' + + // 镜像保留份数(每服务) + IMAGE_KEEP_COUNT = '3' + // 性能配置 - 将动态调整 BUILD_TIMEOUT = 45 DEPLOY_TIMEOUT = 10 @@ -270,6 +279,30 @@ pipeline { } } + stage('Pre-deploy Check') { + when { + allOf { + expression { env.SERVICES_TO_BUILD != '' } + anyOf { + branch 'master' + branch 'release/next' + } + } + } + steps { + script { + def stageStartTime = System.currentTimeMillis() + echo "🛡️ Pre-deploy health check: remote disk & SSH reachability" + + // 检查 Prod 与 Registry 两台主机的磁盘,低于阈值 fail fast,避免 sshd 在磁盘满时被拖垮 + checkRemoteDiskOrFail(env.DEPLOY_HOST, 'Deploy') + checkRemoteDiskOrFail(env.REGISTRY_HOST, 'Registry') + + recordStageMetrics('Pre-deploy Check', stageStartTime) + } + } + } + stage('Deploy') { when { allOf { @@ -283,7 +316,7 @@ pipeline { steps { script { def stageStartTime = System.currentTimeMillis() - + def servicesToDeploy = env.SERVICES_TO_BUILD.split(',') def sortedServices = sortServicesByDependency(servicesToDeploy) @@ -375,6 +408,32 @@ pipeline { } } } + + stage('Cleanup Old Images') { + when { + allOf { + expression { env.SERVICES_TO_BUILD != '' } + anyOf { + branch 'master' + branch 'release/next' + } + } + } + steps { + script { + def stageStartTime = System.currentTimeMillis() + echo "🧹 Cleaning up old images (keep=${env.IMAGE_KEEP_COUNT})" + + // Prod/Staging 本地:清旧镜像 + dangling + builder cache + cleanupDeployHost(env.DEPLOY_HOST, env.IMAGE_KEEP_COUNT) + + // Registry:按保留策略删 manifest + 触发 GC 释放磁盘 + cleanupRegistry(env.REGISTRY_HOST, env.REGISTRY_CONTAINER, env.IMAGE_KEEP_COUNT) + + recordStageMetrics('Cleanup Old Images', stageStartTime) + } + } + } } post { @@ -1073,3 +1132,73 @@ def getModulePathForService(String service) { ] return map.get(service, service) } + +// ============================================ +// 磁盘/清理相关 helper(Prod + Registry) +// ============================================ + +// 检查远端磁盘剩余百分比;低于 MIN 阈值直接 fail;低于 WARN 仅告警 +def checkRemoteDiskOrFail(String host, String role) { + def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}" + def minPct = Integer.parseInt(env.DISK_FREE_MIN_PCT) + def warnPct = Integer.parseInt(env.DISK_FREE_WARN_PCT) + + def freePct + try { + // awk 的 $5+0 会把 "42%" 强转为 42,避免多层 gsub 转义 + freePct = sh( + script: "ssh ${sshOpts} root@${host} \"df -P / | awk 'NR==2 { print 100 - \\\$5+0 }'\"", + returnStdout: true + ).trim() as int + } catch (Exception e) { + error("❌ [${role}] 无法通过 SSH 到 ${host} 读取磁盘(可能 sshd 已被磁盘满拖垮):${e.message}") + } + + echo " ${role}@${host}: 根分区空闲 ${freePct}%" + if (freePct < minPct) { + error("❌ [${role}] ${host} 根分区空闲仅 ${freePct}% < ${minPct}%,终止部署避免二次失败。请先手动清理或跑 scripts/cleanup.sh") + } else if (freePct < warnPct) { + echo "⚠️ [${role}] ${host} 空闲 ${freePct}% < ${warnPct}%,本次部署后会触发自动清理" + } +} + +// Prod/Staging 本地清理:调用仓库内的 cleanup.sh +def cleanupDeployHost(String host, String keep) { + def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}" + try { + echo "📤 Syncing scripts/cleanup.sh to ${host}..." + sh "scp ${sshOpts} scripts/cleanup.sh root@${host}:${env.DEPLOY_PATH}/cleanup.sh" + sh """ + ssh ${sshOpts} root@${host} ' + cd ${env.DEPLOY_PATH} + chmod +x cleanup.sh + ./cleanup.sh --keep=${keep} --registry=${env.REGISTRY} + ' + """ + echo "✅ Deploy host 清理完成" + } catch (Exception e) { + // 清理失败不影响发布结果,仅告警 + echo "⚠️ Deploy host 清理失败(不致命):${e.message}" + } +} + +// Registry 清理:同步 python 脚本 → 按保留策略删 manifest → GC +def cleanupRegistry(String host, String registryContainer, String keep) { + def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}" + try { + echo "📤 Syncing scripts/registry-cleanup.py to ${host}..." + sh "scp ${sshOpts} scripts/registry-cleanup.py root@${host}:/tmp/registry-cleanup.py" + sh """ + ssh ${sshOpts} root@${host} ' + python3 /tmp/registry-cleanup.py \ + --registry http://localhost:5000 \ + --keep ${keep} \ + --repos ${env.CORE_SERVICES} \ + --gc-container ${registryContainer} + ' + """ + echo "✅ Registry 清理 + GC 完成" + } catch (Exception e) { + echo "⚠️ Registry 清理失败(不致命):${e.message}" + } +} diff --git a/scripts/cleanup.sh b/scripts/cleanup.sh index 5107e117..3cddf919 100644 --- a/scripts/cleanup.sh +++ b/scripts/cleanup.sh @@ -2,80 +2,122 @@ # ============================================ # AIOT Platform - 清理脚本 -# 清理旧镜像和容器,释放存储空间 +# 清理部署主机上的旧镜像 / 停止容器 / 构建缓存,释放存储空间 # ============================================ set -e +# ---- 默认参数 ---- +KEEP=3 +PRUNE_VOLUMES=false +REGISTRY_HOST="localhost:5000" + +usage() { + cat </dev/null || true + + # 按创建时间降序取 ID 列表,跳过 latest tag,保留前 KEEP 个 + mapfile -t ids_to_delete < <( + docker images "${REGISTRY_HOST}/${service}" \ + --format '{{.CreatedAt}}|{{.ID}}|{{.Tag}}' \ + | grep -v '|latest$' \ + | sort -r \ + | awk -F'|' -v k="$KEEP" 'NR > k {print $2}' + ) + + if [ "${#ids_to_delete[@]}" -eq 0 ]; then + log_info " └─ 无可清理镜像" + continue + fi + + log_info " └─ 删除 ${#ids_to_delete[@]} 个旧镜像" + # 去重后批量删 + printf '%s\n' "${ids_to_delete[@]}" | sort -u | xargs -r docker rmi -f 2>/dev/null || true done -# 清理未使用的卷(谨慎使用) -log_warn "是否清理未使用的卷? (y/N)" -read -r response -if [ "$response" = "y" ] || [ "$response" = "Y" ]; then - log_info "清理未使用的卷..." +if [ "$PRUNE_VOLUMES" = true ]; then + log_warn "清理未使用的 volume(--prune-volumes 已启用)" docker volume prune -f +else + log_info "跳过 volume 清理(如需清理请加 --prune-volumes)" fi -# 清理构建缓存 -log_info "清理 Docker 构建缓存..." -docker builder prune -f --filter "until=24h" +log_info "清理 Docker 构建缓存(24h 前)..." +docker builder prune -f --filter "until=24h" || true -# 显示清理后的磁盘使用情况 echo "" log_info "=========================================" log_info "清理完成" log_info "=========================================" echo "" log_info "清理后磁盘使用情况:" -df -h | grep -E "Filesystem|/$" +df -h | grep -E "Filesystem|/$" || true echo "" log_info "清理后 Docker 磁盘使用:" docker system df diff --git a/scripts/registry-cleanup.py b/scripts/registry-cleanup.py new file mode 100644 index 00000000..20f1dcc5 --- /dev/null +++ b/scripts/registry-cleanup.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +""" +Docker Registry 镜像清理工具 + +按 tag 语义/时间排序,每个仓库保留最近 N 个版本(默认 3),其余逻辑删除。 +支持可选触发容器内 `registry garbage-collect` 真正回收磁盘空间。 + +典型用法(在 Registry 宿主机上执行): + # 保留最近 3 个 + python3 registry-cleanup.py + + # 指定仓库列表 + GC + python3 registry-cleanup.py \\ + --registry http://localhost:5000 \\ + --keep 3 \\ + --repos viewsh-gateway,viewsh-module-iot-server \\ + --gc-container registry + + # 空跑查看计划(不执行删除) + python3 registry-cleanup.py --dry-run + +退出码:0=成功 / 1=参数错误 / 2=Registry 不可达 / 3=部分仓库处理失败 +""" + +import argparse +import json +import re +import subprocess +import sys +import urllib.error +import urllib.request +from typing import List, Optional, Tuple + +DEFAULT_REGISTRY = "http://localhost:5000" +DEFAULT_KEEP = 3 +DEFAULT_REPOS = [ + "viewsh-gateway", + "viewsh-module-infra-server", + "viewsh-module-iot-gateway", + "viewsh-module-iot-server", + "viewsh-module-ops-server", + "viewsh-module-system-server", +] + +# 覆盖常见的 manifest 媒体类型,避免 BuildKit/OCI 推的 tag 取不到 digest +MANIFEST_ACCEPT = ", ".join([ + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.oci.image.index.v1+json", +]) + + +def http_request(method: str, url: str, headers: Optional[dict] = None, timeout: int = 10): + req = urllib.request.Request(url, method=method, headers=headers or {}) + return urllib.request.urlopen(req, timeout=timeout) + + +def list_tags(registry: str, repo: str) -> List[str]: + url = f"{registry}/v2/{repo}/tags/list" + try: + with http_request("GET", url) as resp: + body = resp.read().decode("utf-8") + except urllib.error.HTTPError as e: + if e.code == 404: + return [] + raise + data = json.loads(body) + return [t for t in (data.get("tags") or []) if t != "latest"] + + +def get_manifest_digest(registry: str, repo: str, tag: str) -> Optional[str]: + url = f"{registry}/v2/{repo}/manifests/{tag}" + try: + with http_request("HEAD", url, headers={"Accept": MANIFEST_ACCEPT}) as resp: + return resp.headers.get("Docker-Content-Digest") + except urllib.error.HTTPError: + return None + + +def delete_manifest(registry: str, repo: str, digest: str) -> bool: + url = f"{registry}/v2/{repo}/manifests/{digest}" + try: + with http_request("DELETE", url) as resp: + return 200 <= resp.status < 300 + except urllib.error.HTTPError as e: + # 202 Accepted 会抛 HTTPError,特殊处理 + return 200 <= e.code < 300 + + +# tag 排序:优先按"数字/build 号"降序,其次按字典序降序 +_BUILD_NUM_RE = re.compile(r"(\d+)") + + +def tag_sort_key(tag: str) -> Tuple[int, str]: + """ + 返回 (数字, 原始tag) 供降序排序使用。 + - 若 tag 包含数字(如 build-123、1.2.3、20260424103000),取最后一个数字段作主排序键 + - 否则数字位 = -1,只用字符串兜底 + """ + nums = _BUILD_NUM_RE.findall(tag) + primary = int(nums[-1]) if nums else -1 + return (primary, tag) + + +def cleanup_repo( + registry: str, + repo: str, + keep: int, + dry_run: bool, +) -> Tuple[int, int, int]: + """ + 返回 (total, deleted, skipped) + """ + tags = list_tags(registry, repo) + total = len(tags) + if total == 0: + print(f" └─ 无 tag,跳过") + return 0, 0, 0 + + # 降序:越新的越靠前 + tags.sort(key=tag_sort_key, reverse=True) + keep_list = tags[:keep] + delete_list = tags[keep:] + + print(f" ├─ 共 {total} 个 tag,保留最新 {len(keep_list)} 个:{keep_list}") + if not delete_list: + print(f" └─ 无需删除") + return total, 0, 0 + + deleted = 0 + skipped = 0 + # 去重:多个 tag 可能指向同一 digest,只需要 DELETE 一次 + seen_digests = set() + for tag in delete_list: + digest = get_manifest_digest(registry, repo, tag) + if not digest: + print(f" │ [SKIP] {tag:30s} (digest 取不到)") + skipped += 1 + continue + if digest in seen_digests: + print(f" │ [DEDUP] {tag:30s} {digest[:19]}...") + continue + seen_digests.add(digest) + if dry_run: + print(f" │ [DRY] {tag:30s} {digest[:19]}...") + deleted += 1 + continue + ok = delete_manifest(registry, repo, digest) + if ok: + print(f" │ [DELETE] {tag:30s} {digest[:19]}...") + deleted += 1 + else: + print(f" │ [FAIL] {tag:30s} {digest[:19]}...") + skipped += 1 + + print(f" └─ 已删除 {deleted},跳过 {skipped}") + return total, deleted, skipped + + +def run_gc(container: str, dry_run: bool) -> bool: + cmd = [ + "docker", "exec", container, + "registry", "garbage-collect", + "--delete-untagged=true", + "/etc/docker/registry/config.yml", + ] + print(f"\n🧹 触发 Registry GC:{' '.join(cmd)}") + if dry_run: + print(" (--dry-run 已跳过实际执行)") + return True + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + except subprocess.TimeoutExpired: + print(" ❌ GC 超时(>5min)") + return False + except FileNotFoundError: + print(" ❌ 找不到 docker 命令,确认脚本跑在 Registry 宿主机") + return False + if result.returncode == 0: + # GC 输出可能很长,只打 tail + tail = "\n".join(result.stdout.splitlines()[-10:]) + print(f" ✅ GC 成功(输出末 10 行):\n{tail}") + return True + print(f" ❌ GC 失败 (rc={result.returncode})") + if result.stderr: + print(f" stderr: {result.stderr.strip()[:500]}") + return False + + +def parse_args(): + p = argparse.ArgumentParser(description="Docker Registry 镜像清理") + p.add_argument("--registry", default=DEFAULT_REGISTRY, help=f"Registry URL(默认 {DEFAULT_REGISTRY})") + p.add_argument("--keep", type=int, default=DEFAULT_KEEP, help=f"每仓库保留版本数(默认 {DEFAULT_KEEP})") + p.add_argument("--repos", default=",".join(DEFAULT_REPOS), + help="逗号分隔的仓库名列表(默认:内置服务清单)") + p.add_argument("--gc-container", default=None, + help="Registry 容器名;指定则删除完后触发 garbage-collect") + p.add_argument("--dry-run", action="store_true", help="只打印计划,不实际删除") + return p.parse_args() + + +def main(): + args = parse_args() + if args.keep < 1: + print("❌ --keep 必须 >= 1", file=sys.stderr) + return 1 + + repos = [r.strip() for r in args.repos.split(",") if r.strip()] + if not repos: + print("❌ 仓库列表为空", file=sys.stderr) + return 1 + + # 连通性检查 + try: + with http_request("GET", f"{args.registry}/v2/", timeout=5): + pass + except (urllib.error.URLError, urllib.error.HTTPError) as e: + # /v2/ 返回 401 也算通(部分 Registry 开启认证) + if not (isinstance(e, urllib.error.HTTPError) and e.code == 401): + print(f"❌ Registry 不可达:{args.registry} — {e}", file=sys.stderr) + return 2 + + print(f"🎯 Registry={args.registry} keep={args.keep} dry_run={args.dry_run}") + print(f"📦 仓库:{repos}\n") + + overall_total = overall_deleted = overall_skipped = 0 + failed_repos = [] + for repo in repos: + print(f"=== {repo} ===") + try: + t, d, s = cleanup_repo(args.registry, repo, args.keep, args.dry_run) + overall_total += t + overall_deleted += d + overall_skipped += s + except Exception as e: + print(f" ❌ 处理异常:{e}") + failed_repos.append(repo) + + print(f"\n📊 总计:扫描 {overall_total} / 删除 {overall_deleted} / 跳过 {overall_skipped}") + if failed_repos: + print(f"⚠️ 失败仓库:{failed_repos}") + + # GC + if args.gc_container and overall_deleted > 0: + ok = run_gc(args.gc_container, args.dry_run) + if not ok: + return 3 + elif args.gc_container: + print("\n🟡 无 manifest 被删除,跳过 GC") + else: + print("\n💡 未指定 --gc-container,逻辑删除完成但磁盘尚未释放;") + print(" 请在 Registry 宿主机手动执行:") + print(f" docker exec registry garbage-collect \\") + print(f" --delete-untagged=true /etc/docker/registry/config.yml") + + return 3 if failed_repos else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sql/mysql/migrations/2026-04-20_01_oauth2_client_platform.sql b/sql/mysql/migrations/2026-04-20_01_oauth2_client_platform.sql new file mode 100644 index 00000000..aae289fa --- /dev/null +++ b/sql/mysql/migrations/2026-04-20_01_oauth2_client_platform.sql @@ -0,0 +1,51 @@ +-- ============================================================ +-- 多前端按 client → platform 过滤菜单 +-- 配合后端代码:AuthController.getPermissionInfo / MenuService.filterMenusByPlatform +-- 日期:2026-04-20 +-- +-- platform 取值约定: +-- biz = 业务平台(对应 OAuth2 客户端 default) +-- iot = 物联运维平台(对应 OAuth2 客户端 iot-client) +-- NULL = 两个平台都展示(通用菜单,如系统管理、用户、部门等) +-- ============================================================ + +-- 1. system_oauth2_client 加 platform 列 +ALTER TABLE `system_oauth2_client` + ADD COLUMN `platform` VARCHAR(10) NULL DEFAULT NULL + COMMENT '平台标识:biz-业务平台,iot-物联运维平台,NULL-不按客户端过滤菜单' + AFTER `additional_information`; + +-- 2. 矫正 system_menu.platform 列的注释(旧注释写的是 ops/sys,与代码约定不一致,更新为 biz/iot) +ALTER TABLE `system_menu` + MODIFY COLUMN `platform` VARCHAR(10) NULL DEFAULT NULL + COMMENT '平台标识:biz-业务平台,iot-物联运维平台,NULL-两个平台都展示'; + +-- 3. 给两个内部 SSO 客户端打 platform 标 +-- 业务平台复用 yudao 默认 default 客户端 → biz +-- 物联运维平台用独立 iot-client → iot +UPDATE `system_oauth2_client` SET `platform` = 'biz' WHERE `client_id` = 'default'; +UPDATE `system_oauth2_client` SET `platform` = 'iot' WHERE `client_id` = 'iot-client'; + +-- 4. 给 IoT 模块的菜单打 iot 标记。从 sql/mysql/system_menu.sql 看, +-- IoT 设备接入 root id=4000,整个子树都属于 iot 平台。 +UPDATE `system_menu` SET `platform` = 'iot' +WHERE `id` IN ( + SELECT t.id FROM ( + -- 递归取 4000 子树。MySQL 8 用 CTE,旧版自行替换为多次 UPDATE + WITH RECURSIVE iot_tree(id) AS ( + SELECT id FROM system_menu WHERE id = 4000 + UNION ALL + SELECT m.id FROM system_menu m JOIN iot_tree it ON m.parent_id = it.id + ) + SELECT id FROM iot_tree + ) t +); + +-- 5. 业务平台独有菜单标 biz(可选;不标的话默认 NULL = 两边都显示) +-- 例如 OA 示例(id=5): +-- UPDATE `system_menu` SET `platform` = 'biz' WHERE `id` IN (5); + +-- 6. 系统管理 / 基础设施 / 用户 / 部门 / 字典 等通用菜单保持 NULL,两边共用。 + +-- 7. 改完客户端后,记得在后台 OAuth2 客户端管理页面"保存"一次刷新缓存; +-- 或重启后端清缓存(Redis key: oauth2_client)。 diff --git a/sql/mysql/migrations/2026-04-20_02_bulk_mark_biz_menus.sql b/sql/mysql/migrations/2026-04-20_02_bulk_mark_biz_menus.sql new file mode 100644 index 00000000..9588cd00 --- /dev/null +++ b/sql/mysql/migrations/2026-04-20_02_bulk_mark_biz_menus.sql @@ -0,0 +1,18 @@ +-- ============================================================ +-- 批量给「非 IoT 菜单」打上 platform='biz' +-- 策略:iot 平台只看设备接入子树(4000),其余(含系统管理、基础设施、OA、各 demo)一律归业务平台 +-- 日期:2026-04-20 +-- ============================================================ + +-- 所有 platform 还是 NULL 的,一律改为 biz +-- (platform='iot' 的行已经在上一次迁移里设过,不会被动) +UPDATE system_menu +SET platform = 'biz' +WHERE deleted = 0 AND platform IS NULL; + +-- 验证 +SELECT platform, COUNT(*) AS cnt FROM system_menu WHERE deleted = 0 GROUP BY platform; +-- 预期: +-- biz = 大部分(系统管理/基础设施/OA/demos 等) +-- iot = 设备接入子树(~50) +-- NULL = 0 diff --git a/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCheckRespDTO.java b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCheckRespDTO.java index 7257f9cd..944b58a5 100644 --- a/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCheckRespDTO.java +++ b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCheckRespDTO.java @@ -18,6 +18,9 @@ public class OAuth2AccessTokenCheckRespDTO implements Serializable { @Schema(description = "用户类型,参见 UserTypeEnum 枚举", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") private Integer userType; + @Schema(description = "OAuth2 客户端编号,用于按客户端区分前端来源(如 default、iot-client)", example = "iot-client") + private String clientId; + @Schema(description = "用户信息", example = "{\"nickname\": \"芋道\"}") private Map userInfo; diff --git a/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/project/ProjectCommonApi.java b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/project/ProjectCommonApi.java index fa29f6b7..266a0690 100644 --- a/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/project/ProjectCommonApi.java +++ b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/project/ProjectCommonApi.java @@ -35,4 +35,11 @@ public interface ProjectCommonApi { @Parameter(name = "userId", description = "用户编号", required = true) CommonResult getDefaultProjectId(@RequestParam("userId") Long userId); + @GetMapping(PREFIX + "/is-authorized") + @Operation(summary = "校验该用户是否有权访问该项目") + @Parameter(name = "userId", description = "用户编号", required = true) + @Parameter(name = "projectId", description = "项目编号", required = true) + CommonResult isProjectAuthorized(@RequestParam("userId") Long userId, + @RequestParam("projectId") Long projectId); + } diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/config/ViewshTenantRpcAutoConfiguration.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/config/ViewshTenantRpcAutoConfiguration.java index 103a981b..d74d92a6 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/config/ViewshTenantRpcAutoConfiguration.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/config/ViewshTenantRpcAutoConfiguration.java @@ -1,6 +1,7 @@ package com.viewsh.framework.tenant.config; import com.viewsh.framework.tenant.core.rpc.TenantRequestInterceptor; +import com.viewsh.framework.common.biz.system.project.ProjectCommonApi; import com.viewsh.framework.common.biz.system.tenant.TenantCommonApi; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -9,7 +10,7 @@ import org.springframework.context.annotation.Bean; @AutoConfiguration @ConditionalOnProperty(prefix = "viewsh.tenant", value = "enable", matchIfMissing = true) // 允许使用 viewsh.tenant.enable=false 禁用多租户 -@EnableFeignClients(clients = TenantCommonApi.class) // 主要是引入相关的 API 服务 +@EnableFeignClients(clients = {TenantCommonApi.class, ProjectCommonApi.class}) // 主要是引入相关的 API 服务 public class ViewshTenantRpcAutoConfiguration { @Bean diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/security/ProjectSecurityWebFilter.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/security/ProjectSecurityWebFilter.java index 83725f0d..9c8d9a0d 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/security/ProjectSecurityWebFilter.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/security/ProjectSecurityWebFilter.java @@ -103,9 +103,8 @@ public class ProjectSecurityWebFilter extends ApiRequestFilter { return; } } else { - // 4. 请求已携带 project-id,校验用户是否有权限访问该项目 - List authorizedProjectIds = projectFrameworkService.getAuthorizedProjectIds(user.getId()); - if (!authorizedProjectIds.contains(projectId)) { + // 4. 请求已携带 project-id,单项校验授权(超管直通;普通用户走主键索引) + if (!projectFrameworkService.isProjectAuthorized(user.getId(), projectId)) { log.error("[doFilterInternal][用户({}) 无权访问项目({}),URL({}/{})]", user.getId(), projectId, request.getRequestURI(), request.getMethod()); ServletUtils.writeJSON(response, CommonResult.error( diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkService.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkService.java index 746df824..7fa60846 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkService.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkService.java @@ -39,6 +39,15 @@ public interface ProjectFrameworkService { */ Long getDefaultProjectId(Long userId); + /** + * 单项校验:该用户是否有权访问该项目(带本地短缓存) + * + * @param userId 用户编号 + * @param projectId 项目编号 + * @return 是否授权 + */ + boolean isProjectAuthorized(Long userId, Long projectId); + /** * 清除用户的授权项目缓存(授权变更时调用) * diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkServiceImpl.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkServiceImpl.java index ed0f2aa5..2fc46272 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkServiceImpl.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkServiceImpl.java @@ -9,6 +9,7 @@ import lombok.SneakyThrows; import java.time.Duration; import java.util.List; +import java.util.Objects; import static com.viewsh.framework.common.util.cache.CacheUtils.buildAsyncReloadingCache; @@ -89,9 +90,42 @@ public class ProjectFrameworkServiceImpl implements ProjectFrameworkService { return projectApi.getDefaultProjectId(userId).getCheckedData(); } + /** + * 针对 {@link #isProjectAuthorized(Long, Long)} 的本地缓存 + * Key: (userId, projectId), Value: 是否授权 + * TTL: 60 秒(授权变更后最长 1 分钟生效;写入点可调 invalidateAuthorizedProjectCache 提前生效) + */ + private final LoadingCache projectAuthorizedCache = buildAsyncReloadingCache( + Duration.ofSeconds(60L), + new CacheLoader() { + + @Override + public Boolean load(AuthKey key) { + return projectApi.isProjectAuthorized(key.userId(), key.projectId()).getCheckedData(); + } + + }); + + @Override + @SneakyThrows + public boolean isProjectAuthorized(Long userId, Long projectId) { + if (userId == null || projectId == null) { + return false; + } + return Boolean.TRUE.equals(projectAuthorizedCache.get(new AuthKey(userId, projectId))); + } + @Override public void invalidateAuthorizedProjectCache(Long userId) { authorizedProjectCache.invalidate(userId); + // 同步清 isProjectAuthorized 中属于该用户的所有缓存条目 + projectAuthorizedCache.asMap().keySet().removeIf(k -> Objects.equals(k.userId(), userId)); + } + + /** + * 缓存 key:(userId, projectId) + */ + private record AuthKey(Long userId, Long projectId) { } } diff --git a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/LoginUser.java b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/LoginUser.java index 11bd62a5..e9698012 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/LoginUser.java +++ b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/LoginUser.java @@ -43,6 +43,14 @@ public class LoginUser { * 授权范围 */ private List scopes; + /** + * OAuth2 客户端编号 + * + * 用于区分用户从哪个前端登录进来,做按客户端的菜单/权限过滤。客户端 → platform 映射: + * - {@code default}(业务平台) → platform=biz + * - {@code iot-client}(物联运维平台) → platform=iot + */ + private String clientId; /** * 过期时间 */ diff --git a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/filter/TokenAuthenticationFilter.java b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/filter/TokenAuthenticationFilter.java index cc7768a0..6d8215fe 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/filter/TokenAuthenticationFilter.java +++ b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/core/filter/TokenAuthenticationFilter.java @@ -97,7 +97,8 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { return new LoginUser().setId(accessToken.getUserId()).setUserType(accessToken.getUserType()) .setInfo(accessToken.getUserInfo()) // 额外的用户信息 .setTenantId(accessToken.getTenantId()).setScopes(accessToken.getScopes()) - .setExpiresTime(accessToken.getExpiresTime()); + .setExpiresTime(accessToken.getExpiresTime()) + .setClientId(accessToken.getClientId()); // OAuth2 客户端编号,用于按前端来源过滤菜单 } catch (ServiceException serviceException) { // 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可 return null; diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/apilog/config/ViewshApiLogAutoConfiguration.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/apilog/config/ViewshApiLogAutoConfiguration.java index e0cfa569..aa27163a 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/apilog/config/ViewshApiLogAutoConfiguration.java +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/apilog/config/ViewshApiLogAutoConfiguration.java @@ -10,14 +10,26 @@ import jakarta.servlet.Filter; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.bind.DefaultValue; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.Collections; +import java.util.List; + @AutoConfiguration(after = ViewshWebAutoConfiguration.class) public class ViewshApiLogAutoConfiguration implements WebMvcConfigurer { + /** + * 访问日志 Interceptor 的排除路径(Ant Pattern)。 + * 例如:{@code viewsh.access-log.exclude-paths=/index/hook/on_server_keepalive,/actuator/**} + * 命中的请求默认不打 INFO 访问日志,但出现异常或 HTTP 4xx/5xx 时仍会打印 WARN。 + */ + @Value("${viewsh.access-log.exclude-paths:}") + private List excludePaths; + /** * 创建 ApiAccessLogFilter Bean,记录 API 请求日志 */ @@ -38,7 +50,8 @@ public class ViewshApiLogAutoConfiguration implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(new ApiAccessLogInterceptor()); + registry.addInterceptor(new ApiAccessLogInterceptor( + excludePaths == null ? Collections.emptyList() : excludePaths)); } } diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java index caefcdbd..bfa73058 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java @@ -9,11 +9,13 @@ import com.viewsh.framework.common.util.spring.SpringUtils; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.util.AntPathMatcher; import org.springframework.util.StopWatch; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import java.lang.reflect.Method; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -24,6 +26,11 @@ import java.util.stream.IntStream; * * 目的:在非 prod 环境时,打印 request 和 response 两条日志到日志文件(控制台)中。 * + *

支持通过 {@code viewsh.access-log.exclude-paths} 配置排除高频心跳/健康检查类请求的日志刷屏, + * 排除路径支持 Ant Pattern(例如 {@code /index/hook/on_server_keepalive}、{@code /actuator/**})。 + * 被排除的请求只有在 发生异常HTTP 状态码 ≥ 400 时才会打印日志, + * 保证"默认安静、出错必响"。 + * * @author 芋道源码 */ @Slf4j @@ -33,6 +40,37 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor { private static final String ATTRIBUTE_STOP_WATCH = "ApiAccessLogInterceptor.StopWatch"; + private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher(); + + /** + * 不打印常规访问日志的路径列表(Ant Pattern)。 + * 命中后:preHandle/afterCompletion 默认不打日志,但若发生异常或 4xx/5xx 仍会打印。 + */ + private final List excludePaths; + + public ApiAccessLogInterceptor() { + this(Collections.emptyList()); + } + + public ApiAccessLogInterceptor(List excludePaths) { + this.excludePaths = excludePaths == null ? Collections.emptyList() : excludePaths; + } + + private boolean isExcluded(String uri) { + if (excludePaths.isEmpty() || uri == null) { + return false; + } + for (String pattern : excludePaths) { + if (StrUtil.isBlank(pattern)) { + continue; + } + if (PATH_MATCHER.match(pattern, uri)) { + return true; + } + } + return false; + } + @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 记录 HandlerMethod,提供给 ApiAccessLogFilter 使用 @@ -43,6 +81,16 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor { // 打印 request 日志 if (!SpringUtils.isProd()) { + // 计时:无论是否排除都要记,afterCompletion 在异常分支还会用它 + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + request.setAttribute(ATTRIBUTE_STOP_WATCH, stopWatch); + + // 命中排除路径:保持安静,等 afterCompletion 里按异常/状态码兜底 + if (isExcluded(request.getRequestURI())) { + return true; + } + Map queryString = ServletUtils.getParamMap(request); String requestBody = ServletUtils.isJsonRequest(request) ? ServletUtils.getBody(request) : null; if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) { @@ -51,10 +99,6 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor { log.info("[preHandle][开始请求 URL({}) 参数({})]", request.getRequestURI(), StrUtil.blankToDefault(requestBody, queryString.toString())); } - // 计时 - StopWatch stopWatch = new StopWatch(); - stopWatch.start(); - request.setAttribute(ATTRIBUTE_STOP_WATCH, stopWatch); // 打印 Controller 路径 printHandlerMethodPosition(handlerMethod); } @@ -66,9 +110,26 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor { // 打印 response 日志 if (!SpringUtils.isProd()) { StopWatch stopWatch = (StopWatch) request.getAttribute(ATTRIBUTE_STOP_WATCH); + if (stopWatch == null) { + return; + } stopWatch.stop(); - log.info("[afterCompletion][完成请求 URL({}) 耗时({} ms)]", - request.getRequestURI(), stopWatch.getTotalTimeMillis()); + String uri = request.getRequestURI(); + boolean excluded = isExcluded(uri); + boolean hasError = ex != null || response.getStatus() >= 400; + // 命中排除 + 正常响应 → 不打日志,保持安静 + if (excluded && !hasError) { + return; + } + if (hasError) { + // 异常/非 2xx 请求显式打到 WARN,方便快速发现 keepalive/健康检查类的异常 + log.warn("[afterCompletion][完成请求 URL({}) 耗时({} ms) 状态({}) 异常({})]", + uri, stopWatch.getTotalTimeMillis(), response.getStatus(), + ex == null ? "-" : ex.getMessage()); + } else { + log.info("[afterCompletion][完成请求 URL({}) 耗时({} ms)]", + uri, stopWatch.getTotalTimeMillis()); + } } } diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/util/WebFrameworkUtils.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/util/WebFrameworkUtils.java index 58102568..bf338666 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/util/WebFrameworkUtils.java +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/util/WebFrameworkUtils.java @@ -1,196 +1,219 @@ -package com.viewsh.framework.web.core.util; - -import cn.hutool.core.util.NumberUtil; -import cn.hutool.extra.servlet.ServletUtil; -import com.viewsh.framework.common.enums.RpcConstants; -import com.viewsh.framework.common.enums.TerminalEnum; -import com.viewsh.framework.common.enums.UserTypeEnum; -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.framework.common.util.servlet.ServletUtils; -import com.viewsh.framework.web.config.WebProperties; -import org.springframework.web.context.request.RequestAttributes; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; - -import jakarta.servlet.ServletRequest; -import jakarta.servlet.http.HttpServletRequest; - -/** - * 专属于 web 包的工具类 - * - * @author 芋道源码 - */ -public class WebFrameworkUtils { - - private static final String REQUEST_ATTRIBUTE_LOGIN_USER_ID = "login_user_id"; - private static final String REQUEST_ATTRIBUTE_LOGIN_USER_TYPE = "login_user_type"; - - private static final String REQUEST_ATTRIBUTE_COMMON_RESULT = "common_result"; - - public static final String HEADER_TENANT_ID = "tenant-id"; - public static final String HEADER_VISIT_TENANT_ID = "visit-tenant-id"; - public static final String HEADER_PROJECT_ID = "project-id"; - - /** - * 终端的 Header - * - * @see com.viewsh.framework.common.enums.TerminalEnum - */ - public static final String HEADER_TERMINAL = "terminal"; - - private static WebProperties properties; - - public WebFrameworkUtils(WebProperties webProperties) { - WebFrameworkUtils.properties = webProperties; - } - - /** - * 获得租户编号,从 header 中 - * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 - * - * @param request 请求 - * @return 租户编号 - */ - public static Long getTenantId(HttpServletRequest request) { - String tenantId = request.getHeader(HEADER_TENANT_ID); - return NumberUtil.isNumber(tenantId) ? Long.valueOf(tenantId) : null; - } - - /** - * 获得项目编号,从 header 中 - * 考虑到其它 framework 组件也会使用到项目编号,所以不得不放在 WebFrameworkUtils 统一提供 - * - * @param request 请求 - * @return 项目编号 - */ - public static Long getProjectId(HttpServletRequest request) { - String projectId = request.getHeader(HEADER_PROJECT_ID); - return NumberUtil.isNumber(projectId) ? Long.valueOf(projectId) : null; - } - - /** - * 获得访问的租户编号,从 header 中 - * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 - * - * @param request 请求 - * @return 租户编号 - */ - public static Long getVisitTenantId(HttpServletRequest request) { - String tenantId = request.getHeader(HEADER_VISIT_TENANT_ID); - return NumberUtil.isNumber(tenantId)? Long.valueOf(tenantId) : null; - } - - public static void setLoginUserId(ServletRequest request, Long userId) { - request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID, userId); - } - - /** - * 设置用户类型 - * - * @param request 请求 - * @param userType 用户类型 - */ - public static void setLoginUserType(ServletRequest request, Integer userType) { - request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE, userType); - } - - /** - * 获得当前用户的编号,从请求中 - * 注意:该方法仅限于 framework 框架使用!!! - * - * @param request 请求 - * @return 用户编号 - */ - public static Long getLoginUserId(HttpServletRequest request) { - if (request == null) { - return null; - } - return (Long) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID); - } - - /** - * 获得当前用户的类型 - * 注意:该方法仅限于 web 相关的 framework 组件使用!!! - * - * @param request 请求 - * @return 用户编号 - */ - public static Integer getLoginUserType(HttpServletRequest request) { - if (request == null) { - return null; - } - // 1. 优先,从 Attribute 中获取 - Integer userType = (Integer) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE); - if (userType != null) { - return userType; - } - // 2. 其次,基于 URL 前缀的约定 - if (request.getServletPath().startsWith(properties.getAdminApi().getPrefix())) { - return UserTypeEnum.ADMIN.getValue(); - } - if (request.getServletPath().startsWith(properties.getAppApi().getPrefix())) { - return UserTypeEnum.MEMBER.getValue(); - } - return null; - } - - public static Integer getLoginUserType() { - HttpServletRequest request = getRequest(); - return getLoginUserType(request); - } - - public static Long getLoginUserId() { - HttpServletRequest request = getRequest(); - return getLoginUserId(request); - } - - public static Integer getTerminal() { - HttpServletRequest request = getRequest(); - if (request == null) { - return TerminalEnum.UNKNOWN.getTerminal(); - } - String terminalValue = request.getHeader(HEADER_TERMINAL); - return NumberUtil.parseInt(terminalValue, TerminalEnum.UNKNOWN.getTerminal()); - } - - public static void setCommonResult(ServletRequest request, CommonResult result) { - request.setAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT, result); - } - - public static CommonResult getCommonResult(ServletRequest request) { - return (CommonResult) request.getAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT); - } - - @SuppressWarnings("PatternVariableCanBeUsed") - public static HttpServletRequest getRequest() { - RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); - if (!(requestAttributes instanceof ServletRequestAttributes)) { - return null; - } - ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes; - return servletRequestAttributes.getRequest(); - } - - /** - * 判断是否为 RPC 请求 - * - * @param request 请求 - * @return 是否为 RPC 请求 - */ - public static boolean isRpcRequest(HttpServletRequest request) { - return request.getRequestURI().startsWith(RpcConstants.RPC_API_PREFIX); - } - - /** - * 判断是否为 RPC 请求 - * - * 约定大于配置,只要以 Api 结尾,都认为是 RPC 接口 - * - * @param className 类名 - * @return 是否为 RPC 请求 - */ - public static boolean isRpcRequest(String className) { - return className.endsWith("Api"); - } - -} +package com.viewsh.framework.web.core.util; + +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.servlet.ServletUtil; +import com.viewsh.framework.common.enums.RpcConstants; +import com.viewsh.framework.common.enums.TerminalEnum; +import com.viewsh.framework.common.enums.UserTypeEnum; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.util.servlet.ServletUtils; +import com.viewsh.framework.web.config.WebProperties; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import jakarta.servlet.ServletRequest; +import jakarta.servlet.http.HttpServletRequest; + +/** + * 专属于 web 包的工具类 + * + * @author 芋道源码 + */ +public class WebFrameworkUtils { + + private static final String REQUEST_ATTRIBUTE_LOGIN_USER_ID = "login_user_id"; + private static final String REQUEST_ATTRIBUTE_LOGIN_USER_TYPE = "login_user_type"; + + private static final String REQUEST_ATTRIBUTE_COMMON_RESULT = "common_result"; + + public static final String HEADER_TENANT_ID = "tenant-id"; + public static final String HEADER_VISIT_TENANT_ID = "visit-tenant-id"; + public static final String HEADER_PROJECT_ID = "project-id"; + /** + * OAuth2 客户端编号 Header + * + * 由前端(业务平台 / 物联运维平台)声明自己是哪个 OAuth2 client, + * 后端在密码登录、refresh-token 等"还没有 token 的入口"用它代替写死的 default。 + * 一旦 token 创建出来,后续请求就走 token 自带的 client_id,无需此 Header。 + */ + public static final String HEADER_CLIENT_ID = "X-Client-Id"; + + /** + * 终端的 Header + * + * @see com.viewsh.framework.common.enums.TerminalEnum + */ + public static final String HEADER_TERMINAL = "terminal"; + + private static WebProperties properties; + + public WebFrameworkUtils(WebProperties webProperties) { + WebFrameworkUtils.properties = webProperties; + } + + /** + * 获得租户编号,从 header 中 + * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 + * + * @param request 请求 + * @return 租户编号 + */ + public static Long getTenantId(HttpServletRequest request) { + String tenantId = request.getHeader(HEADER_TENANT_ID); + return NumberUtil.isNumber(tenantId) ? Long.valueOf(tenantId) : null; + } + + /** + * 获得项目编号,从 header 中 + * 考虑到其它 framework 组件也会使用到项目编号,所以不得不放在 WebFrameworkUtils 统一提供 + * + * @param request 请求 + * @return 项目编号 + */ + public static Long getProjectId(HttpServletRequest request) { + String projectId = request.getHeader(HEADER_PROJECT_ID); + return NumberUtil.isNumber(projectId) ? Long.valueOf(projectId) : null; + } + + /** + * 获得 OAuth2 客户端编号,从 header 中。 + * 未携带时返回 null,由调用方决定回退默认值。 + * + * @param request 请求 + * @return 客户端编号 + */ + public static String getClientId(HttpServletRequest request) { + if (request == null) { + return null; + } + return StrUtil.trimToNull(request.getHeader(HEADER_CLIENT_ID)); + } + + /** + * 获得访问的租户编号,从 header 中 + * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 + * + * @param request 请求 + * @return 租户编号 + */ + public static Long getVisitTenantId(HttpServletRequest request) { + String tenantId = request.getHeader(HEADER_VISIT_TENANT_ID); + return NumberUtil.isNumber(tenantId)? Long.valueOf(tenantId) : null; + } + + public static void setLoginUserId(ServletRequest request, Long userId) { + request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID, userId); + } + + /** + * 设置用户类型 + * + * @param request 请求 + * @param userType 用户类型 + */ + public static void setLoginUserType(ServletRequest request, Integer userType) { + request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE, userType); + } + + /** + * 获得当前用户的编号,从请求中 + * 注意:该方法仅限于 framework 框架使用!!! + * + * @param request 请求 + * @return 用户编号 + */ + public static Long getLoginUserId(HttpServletRequest request) { + if (request == null) { + return null; + } + return (Long) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID); + } + + /** + * 获得当前用户的类型 + * 注意:该方法仅限于 web 相关的 framework 组件使用!!! + * + * @param request 请求 + * @return 用户编号 + */ + public static Integer getLoginUserType(HttpServletRequest request) { + if (request == null) { + return null; + } + // 1. 优先,从 Attribute 中获取 + Integer userType = (Integer) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE); + if (userType != null) { + return userType; + } + // 2. 其次,基于 URL 前缀的约定 + if (request.getServletPath().startsWith(properties.getAdminApi().getPrefix())) { + return UserTypeEnum.ADMIN.getValue(); + } + if (request.getServletPath().startsWith(properties.getAppApi().getPrefix())) { + return UserTypeEnum.MEMBER.getValue(); + } + return null; + } + + public static Integer getLoginUserType() { + HttpServletRequest request = getRequest(); + return getLoginUserType(request); + } + + public static Long getLoginUserId() { + HttpServletRequest request = getRequest(); + return getLoginUserId(request); + } + + public static Integer getTerminal() { + HttpServletRequest request = getRequest(); + if (request == null) { + return TerminalEnum.UNKNOWN.getTerminal(); + } + String terminalValue = request.getHeader(HEADER_TERMINAL); + return NumberUtil.parseInt(terminalValue, TerminalEnum.UNKNOWN.getTerminal()); + } + + public static void setCommonResult(ServletRequest request, CommonResult result) { + request.setAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT, result); + } + + public static CommonResult getCommonResult(ServletRequest request) { + return (CommonResult) request.getAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT); + } + + @SuppressWarnings("PatternVariableCanBeUsed") + public static HttpServletRequest getRequest() { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (!(requestAttributes instanceof ServletRequestAttributes)) { + return null; + } + ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes; + return servletRequestAttributes.getRequest(); + } + + /** + * 判断是否为 RPC 请求 + * + * @param request 请求 + * @return 是否为 RPC 请求 + */ + public static boolean isRpcRequest(HttpServletRequest request) { + return request.getRequestURI().startsWith(RpcConstants.RPC_API_PREFIX); + } + + /** + * 判断是否为 RPC 请求 + * + * 约定大于配置,只要以 Api 结尾,都认为是 RPC 接口 + * + * @param className 类名 + * @return 是否为 RPC 请求 + */ + public static boolean isRpcRequest(String className) { + return className.endsWith("Api"); + } + +} diff --git a/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/LoginUser.java b/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/LoginUser.java index 030cd839..763ad629 100644 --- a/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/LoginUser.java +++ b/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/LoginUser.java @@ -36,6 +36,10 @@ public class LoginUser { * 授权范围 */ private List scopes; + /** + * OAuth2 客户端编号 + */ + private String clientId; /** * 过期时间 */ diff --git a/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/TokenAuthenticationFilter.java b/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/TokenAuthenticationFilter.java index 484b4786..4f8bca9c 100644 --- a/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/TokenAuthenticationFilter.java +++ b/viewsh-gateway/src/main/java/com/viewsh/gateway/filter/security/TokenAuthenticationFilter.java @@ -157,7 +157,8 @@ public class TokenAuthenticationFilter implements GlobalFilter, Ordered { return new LoginUser().setId(tokenInfo.getUserId()).setUserType(tokenInfo.getUserType()) .setInfo(tokenInfo.getUserInfo()) // 额外的用户信息 .setTenantId(tokenInfo.getTenantId()).setScopes(tokenInfo.getScopes()) - .setExpiresTime(tokenInfo.getExpiresTime()); + .setExpiresTime(tokenInfo.getExpiresTime()) + .setClientId(tokenInfo.getClientId()); // OAuth2 客户端编号,下游按它做按前端来源过滤 } @Override diff --git a/viewsh-gateway/src/main/resources/application.yaml b/viewsh-gateway/src/main/resources/application.yaml index 77ca8df5..bcfd4475 100644 --- a/viewsh-gateway/src/main/resources/application.yaml +++ b/viewsh-gateway/src/main/resources/application.yaml @@ -225,6 +225,9 @@ spring: - Path=/app-api/video/** filters: - RewritePath=/app-api/video/v3/api-docs, /v3/api-docs + # 注意:ZLM Hook 回调 (/index/hook/**) 不走网关,由 ZLM 直连 video-server 的 Tomcat 端口 + # 原因:Hook 是东西向 M2M 流量(ZLM ↔ video-server),对延迟敏感,经网关会引入 + # 多余的单点 + 日志噪音;业界实践都是让流媒体 Hook 直连业务服务 x-forwarded: prefix-enabled: false # 避免 Swagger 重复带上额外的 /admin-api/system 前缀 diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/CleanRuleProcessorManager.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/CleanRuleProcessorManager.java index acf13bf1..59a163b9 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/CleanRuleProcessorManager.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/CleanRuleProcessorManager.java @@ -10,6 +10,7 @@ import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; import java.util.Map; /** @@ -60,6 +61,7 @@ public class CleanRuleProcessorManager { } Long deviceId = message.getDeviceId(); + LocalDateTime reportTime = message.getReportTime(); @SuppressWarnings("unchecked") Map data = (Map) message.getParams(); @@ -73,7 +75,7 @@ public class CleanRuleProcessorManager { } else { // 属性上报:直接遍历 key-value data.forEach((identifier, value) -> - processDataSafely(deviceId, identifier, value)); + processDataSafely(deviceId, identifier, value, reportTime)); // 4. 蓝牙信号缺失补偿:当设备上报了属性但不含 bluetoothDevices 时, // 主动注入一次 null 调用,使 BeaconDetectionRuleProcessor 能写入 -999(信号缺失), @@ -81,7 +83,7 @@ public class CleanRuleProcessorManager { if (!data.containsKey("bluetoothDevices")) { beaconDetectionRuleProcessor.processPropertyChange(deviceId, "bluetoothDevices", null); // 轨迹检测同样需要信号丢失补偿,注入 null 使窗口写入 -999 - trajectoryDetectionProcessor.processPropertyChange(deviceId, "bluetoothDevices", null); + trajectoryDetectionProcessor.processPropertyChange(deviceId, "bluetoothDevices", null, reportTime); } } } @@ -127,7 +129,7 @@ public class CleanRuleProcessorManager { * @param identifier 标识符 * @param value 数据值 */ - private void processDataSafely(Long deviceId, String identifier, Object value) { + private void processDataSafely(Long deviceId, String identifier, Object value, LocalDateTime reportTime) { try { switch (identifier) { case "people_in", "people_out" -> @@ -135,7 +137,7 @@ public class CleanRuleProcessorManager { case "bluetoothDevices" -> { beaconDetectionRuleProcessor.processPropertyChange(deviceId, identifier, value); // 轨迹检测:独立于保洁到岗检测,匹配所有已知 Beacon - trajectoryDetectionProcessor.processPropertyChange(deviceId, identifier, value); + trajectoryDetectionProcessor.processPropertyChange(deviceId, identifier, value, reportTime); } default -> { // 其他属性/事件忽略 diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrajectoryDetectionProcessor.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrajectoryDetectionProcessor.java index b6fe4b8f..933e9754 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrajectoryDetectionProcessor.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrajectoryDetectionProcessor.java @@ -24,6 +24,7 @@ import org.springframework.stereotype.Component; import com.viewsh.framework.tenant.core.context.TenantContextHolder; +import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.TimeUnit; @@ -57,6 +58,17 @@ public class TrajectoryDetectionProcessor { private static final String DEVICE_ENABLED_KEY_PATTERN = "iot:trajectory:device:enabled:%s"; private static final int DEVICE_ENABLED_TTL_SECONDS = 3600; // 1小时 + /** + * 最小停留时长(毫秒):进入区域后至少停留这么久,才允许发布 LEAVE/AREA_SWITCH, + * 用于过滤 RSSI 抖动和批量消息回放导致的瞬态切换 + */ + private static final long MIN_STAY_MILLIS = 5_000L; + + /** + * 区域切换滞回阈值(dB):候选区域 RSSI 必须比当前区域 RSSI 高出此值,才允许 AREA_SWITCH + */ + private static final int RSSI_HYSTERESIS_DB = 5; + @Resource private BeaconRegistryService beaconRegistryService; @@ -84,11 +96,20 @@ public class TrajectoryDetectionProcessor { * @param deviceId 设备ID(工牌) * @param identifier 属性标识符 * @param propertyValue 蓝牙设备列表 + * @param reportTime 设备上报时间(可为 null,为空则用当前时间兜底) */ - public void processPropertyChange(Long deviceId, String identifier, Object propertyValue) { + public void processPropertyChange(Long deviceId, String identifier, Object propertyValue, + LocalDateTime reportTime) { if (!"bluetoothDevices".equals(identifier)) { return; } + LocalDateTime eventTime; + if (reportTime != null) { + eventTime = reportTime; + } else { + eventTime = LocalDateTime.now(); + log.warn("[Trajectory] reportTime 为空,使用当前时间兜底:deviceId={}(若频繁出现请排查上游调用链)", deviceId); + } // 1. 检查设备是否开启轨迹功能 if (!isTrajectoryEnabled(deviceId)) { @@ -125,9 +146,9 @@ public class TrajectoryDetectionProcessor { // 8. 处理区域状态变化 if (currentArea != null) { - processWithCurrentArea(deviceId, currentArea, matchedBeacons, areaConfigIndex); + processWithCurrentArea(deviceId, currentArea, matchedBeacons, areaConfigIndex, eventTime); } else { - processWithoutCurrentArea(deviceId, matchedBeacons, areaConfigIndex); + processWithoutCurrentArea(deviceId, matchedBeacons, areaConfigIndex, eventTime); } } @@ -139,8 +160,11 @@ public class TrajectoryDetectionProcessor { private void processWithCurrentArea(Long deviceId, TrajectoryStateRedisDAO.CurrentAreaInfo currentArea, Map matchedBeacons, - Map areaConfigIndex) { + Map areaConfigIndex, + LocalDateTime eventTime) { Long currentAreaId = currentArea.getAreaId(); + long stayMillis = System.currentTimeMillis() - (currentArea.getEnterTime() != null ? currentArea.getEnterTime() : 0L); + boolean minStayReached = stayMillis >= MIN_STAY_MILLIS; // 6a. 检查当前区域的退出条件 BeaconPresenceConfig currentConfig = areaConfigIndex.get(currentAreaId); @@ -153,16 +177,22 @@ public class TrajectoryDetectionProcessor { AreaState.IN_AREA); if (exitResult == DetectionResult.LEAVE_CONFIRMED) { + if (!minStayReached) { + // 未达到最小停留时长,视为瞬态抖动,忽略本次离开 + log.debug("[Trajectory] 未达最小停留,忽略 LEAVE:deviceId={}, areaId={}, stayMs={}", + deviceId, currentAreaId, stayMillis); + return; + } // 确认离开当前区域 publishLeaveEvent(deviceId, currentAreaId, currentArea.getBeaconMac(), - "SIGNAL_LOSS", currentArea.getEnterTime()); + "SIGNAL_LOSS", currentArea.getEnterTime(), eventTime); stateRedisDAO.clearCurrentArea(deviceId); windowRedisDAO.clearWindow(deviceId, currentAreaId); log.info("[Trajectory] 离开区域:deviceId={}, areaId={}, reason=SIGNAL_LOSS", deviceId, currentAreaId); // 离开后,尝试进入新区域 - processWithoutCurrentArea(deviceId, matchedBeacons, areaConfigIndex); + processWithoutCurrentArea(deviceId, matchedBeacons, areaConfigIndex, eventTime); return; } } @@ -170,15 +200,29 @@ public class TrajectoryDetectionProcessor { // 6b. 当前区域未退出,检查是否有更强区域触发切换 MatchedBeacon bestCandidate = findBestEnterCandidate(deviceId, matchedBeacons, currentAreaId); if (bestCandidate != null && !bestCandidate.areaId.equals(currentAreaId)) { + if (!minStayReached) { + log.debug("[Trajectory] 未达最小停留,忽略 AREA_SWITCH:deviceId={}, from={}, to={}, stayMs={}", + deviceId, currentAreaId, bestCandidate.areaId, stayMillis); + return; + } + // 切换滞回:候选 RSSI 必须显著强于当前区域 RSSI + // 优先取本次匹配值;若当前未匹配到,回退到窗口里最近一次非缺失(-999)样本, + // 避免当前信标短暂漏扫时滞回被 -999 哨兵破坏 + int currentRssi = resolveCurrentAreaRssi(deviceId, currentAreaId, matchedBeacons); + if (bestCandidate.rssi - currentRssi < RSSI_HYSTERESIS_DB) { + log.debug("[Trajectory] 未达滞回阈值,忽略 AREA_SWITCH:deviceId={}, from={}({}dBm), to={}({}dBm)", + deviceId, currentAreaId, currentRssi, bestCandidate.areaId, bestCandidate.rssi); + return; + } // 区域切换:先离开当前区域,再进入新区域 publishLeaveEvent(deviceId, currentAreaId, currentArea.getBeaconMac(), - "AREA_SWITCH", currentArea.getEnterTime()); + "AREA_SWITCH", currentArea.getEnterTime(), eventTime); windowRedisDAO.clearWindow(deviceId, currentAreaId); long now = System.currentTimeMillis(); stateRedisDAO.setCurrentArea(deviceId, bestCandidate.areaId, now, bestCandidate.beaconMac); windowRedisDAO.clearWindow(deviceId, bestCandidate.areaId); - publishEnterEvent(deviceId, bestCandidate.areaId, bestCandidate.beaconMac, bestCandidate.rssi); + publishEnterEvent(deviceId, bestCandidate.areaId, bestCandidate.beaconMac, bestCandidate.rssi, eventTime); log.info("[Trajectory] 区域切换:deviceId={}, from={}, to={}", deviceId, currentAreaId, bestCandidate.areaId); } @@ -189,13 +233,14 @@ public class TrajectoryDetectionProcessor { */ private void processWithoutCurrentArea(Long deviceId, Map matchedBeacons, - Map areaConfigIndex) { + Map areaConfigIndex, + LocalDateTime eventTime) { MatchedBeacon bestCandidate = findBestEnterCandidate(deviceId, matchedBeacons, null); if (bestCandidate != null) { long now = System.currentTimeMillis(); stateRedisDAO.setCurrentArea(deviceId, bestCandidate.areaId, now, bestCandidate.beaconMac); windowRedisDAO.clearWindow(deviceId, bestCandidate.areaId); - publishEnterEvent(deviceId, bestCandidate.areaId, bestCandidate.beaconMac, bestCandidate.rssi); + publishEnterEvent(deviceId, bestCandidate.areaId, bestCandidate.beaconMac, bestCandidate.rssi, eventTime); log.info("[Trajectory] 进入区域:deviceId={}, areaId={}, rssi={}", deviceId, bestCandidate.areaId, bestCandidate.rssi); } @@ -282,6 +327,31 @@ public class TrajectoryDetectionProcessor { } } + /** + * 解析当前区域用于滞回判断的参考 RSSI: + * 1) 本次上报匹配到了当前区域 → 直接用本次 RSSI + * 2) 未匹配到 → 取窗口里最近一次非 -999(缺失哨兵)样本 + * 3) 仍取不到 → 返回 -999(此时滞回天然放行,等价于允许切换, + * 因为当前区域已彻底失去信号) + */ + private int resolveCurrentAreaRssi(Long deviceId, Long currentAreaId, + Map matchedBeacons) { + MatchedBeacon matched = matchedBeacons.get(currentAreaId); + if (matched != null) { + return matched.rssi; + } + List window = windowRedisDAO.getWindow(deviceId, currentAreaId); + if (window != null) { + for (int i = window.size() - 1; i >= 0; i--) { + Integer sample = window.get(i); + if (sample != null && sample != -999) { + return sample; + } + } + } + return -999; + } + /** * 找到信号最强且满足进入条件的候选区域 * @@ -366,7 +436,8 @@ public class TrajectoryDetectionProcessor { // ==================== 事件发布 ==================== - private void publishEnterEvent(Long deviceId, Long areaId, String beaconMac, Integer enterRssi) { + private void publishEnterEvent(Long deviceId, Long areaId, String beaconMac, Integer enterRssi, + LocalDateTime eventTime) { try { IotDeviceDO device = deviceService.getDeviceFromCache(deviceId); TrajectoryEnterEvent event = TrajectoryEnterEvent.builder() @@ -376,6 +447,7 @@ public class TrajectoryDetectionProcessor { .areaId(areaId) .beaconMac(beaconMac) .enterRssi(enterRssi) + .eventTime((eventTime != null ? eventTime : LocalDateTime.now()).toString()) .tenantId(TenantContextHolder.getTenantId()) .build(); @@ -390,7 +462,7 @@ public class TrajectoryDetectionProcessor { } private void publishLeaveEvent(Long deviceId, Long areaId, String beaconMac, - String leaveReason, Long enterTimestamp) { + String leaveReason, Long enterTimestamp, LocalDateTime eventTime) { try { IotDeviceDO device = deviceService.getDeviceFromCache(deviceId); TrajectoryLeaveEvent event = TrajectoryLeaveEvent.builder() @@ -401,6 +473,7 @@ public class TrajectoryDetectionProcessor { .beaconMac(beaconMac) .leaveReason(leaveReason) .enterTimestamp(enterTimestamp) + .eventTime((eventTime != null ? eventTime : LocalDateTime.now()).toString()) .tenantId(TenantContextHolder.getTenantId()) .build(); diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/api/project/ProjectApiImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/api/project/ProjectApiImpl.java index 20111d8b..8baa5d03 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/api/project/ProjectApiImpl.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/api/project/ProjectApiImpl.java @@ -41,4 +41,9 @@ public class ProjectApiImpl implements ProjectCommonApi { return success(projectService.getDefaultProjectId(userId)); } + @Override + public CommonResult isProjectAuthorized(Long userId, Long projectId) { + return success(projectService.isProjectAuthorized(userId, projectId)); + } + } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java index a1154b47..91dec7fd 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java @@ -6,14 +6,17 @@ import com.viewsh.framework.common.enums.CommonStatusEnum; import com.viewsh.framework.common.enums.UserTypeEnum; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.security.config.SecurityProperties; +import com.viewsh.framework.security.core.LoginUser; import com.viewsh.framework.security.core.util.SecurityFrameworkUtils; import com.viewsh.module.system.controller.admin.auth.vo.*; import com.viewsh.module.system.convert.auth.AuthConvert; +import com.viewsh.module.system.dal.dataobject.oauth2.OAuth2ClientDO; import com.viewsh.module.system.dal.dataobject.permission.MenuDO; import com.viewsh.module.system.dal.dataobject.permission.RoleDO; import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; import com.viewsh.module.system.enums.logger.LoginLogTypeEnum; import com.viewsh.module.system.service.auth.AdminAuthService; +import com.viewsh.module.system.service.oauth2.OAuth2ClientService; import com.viewsh.module.system.service.permission.MenuService; import com.viewsh.module.system.service.permission.PermissionService; import com.viewsh.module.system.service.permission.RoleService; @@ -58,6 +61,8 @@ public class AuthController { private PermissionService permissionService; @Resource private SocialClientService socialClientService; + @Resource + private OAuth2ClientService oauth2ClientService; @Resource private SecurityProperties securityProperties; @@ -98,8 +103,8 @@ public class AuthController { return success(null); } - // 1.2 获得角色列表 - Set roleIds = permissionService.getUserRoleIdListByUserId(getLoginUserId()); + // 1.2 获得角色列表(走 Redis 缓存,避免每次登录/重连都打 system_user_role) + Set roleIds = permissionService.getUserRoleIdListByUserIdFromCache(getLoginUserId()); if (CollUtil.isEmpty(roleIds)) { return success(AuthConvert.INSTANCE.convert(user, Collections.emptyList(), Collections.emptyList())); } @@ -110,11 +115,27 @@ public class AuthController { Set menuIds = permissionService.getRoleMenuListByRoleId(convertSet(roles, RoleDO::getId)); List menuList = menuService.getMenuList(menuIds); menuList = menuService.filterDisableMenus(menuList); + // 1.4 按当前登录的 OAuth2 客户端的 platform,过滤菜单(platform 为空的菜单两端都展示) + // 例:iot-client 登录 → 只下发 platform=iot 或 NULL 的菜单;default 登录 → 只下发 platform=biz 或 NULL 的菜单 + menuList = menuService.filterMenusByPlatform(menuList, getCurrentClientPlatform()); // 2. 拼接结果返回 return success(AuthConvert.INSTANCE.convert(user, roles, menuList)); } + /** + * 取当前登录用户所用的 OAuth2 客户端的 platform 字段,用于按前端来源过滤菜单。 + * 缺省(未登录、客户端不存在或客户端未配置 platform)返回 null,表示不做过滤。 + */ + private String getCurrentClientPlatform() { + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser == null || StrUtil.isBlank(loginUser.getClientId())) { + return null; + } + OAuth2ClientDO client = oauth2ClientService.getOAuth2ClientFromCache(loginUser.getClientId()); + return client == null ? null : client.getPlatform(); + } + @PostMapping("/register") @PermitAll @Operation(summary = "注册用户") diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuRespVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuRespVO.java index 4b0d2b70..cb5a7d13 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuRespVO.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuRespVO.java @@ -63,6 +63,9 @@ public class MenuRespVO { @Schema(description = "是否总是显示", example = "false") private Boolean alwaysShow; + @Schema(description = "平台标识:biz-业务平台,iot-物联运维平台,null-两个平台都展示", example = "biz") + private String platform; + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式") private LocalDateTime createTime; diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuSaveVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuSaveVO.java index 77ba467f..eee1a830 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuSaveVO.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/permission/vo/menu/MenuSaveVO.java @@ -61,4 +61,7 @@ public class MenuSaveVO { @Schema(description = "是否总是显示", example = "false") private Boolean alwaysShow; + @Schema(description = "平台标识:biz-业务平台,iot-物联运维平台,null-两个平台都展示", example = "biz") + private String platform; + } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/UserProjectController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/UserProjectController.java index 061082ce..420219fc 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/UserProjectController.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/UserProjectController.java @@ -1,8 +1,14 @@ package com.viewsh.module.system.controller.admin.project; import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.common.util.object.BeanUtils; +import com.viewsh.module.system.controller.admin.project.vo.UserProjectAddProjectUsersReqVO; import com.viewsh.module.system.controller.admin.project.vo.UserProjectAssignProjectUsersReqVO; import com.viewsh.module.system.controller.admin.project.vo.UserProjectAssignUserProjectsReqVO; +import com.viewsh.module.system.controller.admin.project.vo.UserProjectPageReqVO; +import com.viewsh.module.system.controller.admin.user.vo.user.UserRespVO; +import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; import com.viewsh.module.system.service.project.UserProjectService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -10,6 +16,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; +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; @@ -70,4 +77,32 @@ public class UserProjectController { return success(userProjectService.getUserIdsByProjectId(projectId)); } + @GetMapping("/project-user-page") + @Operation(summary = "分页查询项目成员", description = "自动过滤超级管理员;支持按 username/nickname/mobile 模糊搜索") + @PreAuthorize("@ss.hasPermission('system:project:assign-user')") + public CommonResult> getProjectUserPage(@Valid UserProjectPageReqVO reqVO) { + PageResult page = userProjectService.getProjectUserPage(reqVO); + return success(BeanUtils.toBean(page, UserRespVO.class)); + } + + @PostMapping("/add-project-users") + @Operation(summary = "增量给项目添加成员", + description = "已经是成员的用户跳过,只插入新成员;不影响已有绑定") + @PreAuthorize("@ss.hasPermission('system:project:assign-user')") + public CommonResult addProjectUsers(@Valid @RequestBody UserProjectAddProjectUsersReqVO reqVO) { + userProjectService.addProjectUsers(reqVO.getProjectId(), reqVO.getUserIds()); + return success(true); + } + + @DeleteMapping("/remove-project-user") + @Operation(summary = "从项目中移除单个成员") + @Parameter(name = "projectId", description = "项目编号", required = true, example = "101") + @Parameter(name = "userId", description = "用户编号", required = true, example = "11") + @PreAuthorize("@ss.hasPermission('system:project:assign-user')") + public CommonResult removeProjectUser(@RequestParam("projectId") Long projectId, + @RequestParam("userId") Long userId) { + userProjectService.removeProjectUser(projectId, userId); + return success(true); + } + } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/UserProjectAddProjectUsersReqVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/UserProjectAddProjectUsersReqVO.java new file mode 100644 index 00000000..f04838cd --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/UserProjectAddProjectUsersReqVO.java @@ -0,0 +1,27 @@ +package com.viewsh.module.system.controller.admin.project.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.Set; + +/** + * 管理后台 - 增量给项目添加成员 Req VO + * + * @author lzh + */ +@Schema(description = "管理后台 - 增量给项目添加成员 Req VO") +@Data +public class UserProjectAddProjectUsersReqVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "101") + @NotNull(message = "项目编号不能为空") + private Long projectId; + + @Schema(description = "要新增的用户编号集合", requiredMode = Schema.RequiredMode.REQUIRED, example = "11,12,13") + @NotEmpty(message = "用户编号集合不能为空") + private Set userIds; + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/UserProjectPageReqVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/UserProjectPageReqVO.java new file mode 100644 index 00000000..bec8c4c1 --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/UserProjectPageReqVO.java @@ -0,0 +1,28 @@ +package com.viewsh.module.system.controller.admin.project.vo; + +import com.viewsh.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * 管理后台 - 项目成员分页查询 Req VO + * + * @author lzh + */ +@Schema(description = "管理后台 - 项目成员分页查询 Req VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class UserProjectPageReqVO extends PageParam { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "101") + @NotNull(message = "项目编号不能为空") + private Long projectId; + + @Schema(description = "关键字(按 username / nickname / mobile 模糊)", example = "张三") + private String keyword; + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/sso/SsoCallbackController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/sso/SsoCallbackController.java new file mode 100644 index 00000000..24bf0f4e --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/sso/SsoCallbackController.java @@ -0,0 +1,104 @@ +package com.viewsh.module.system.controller.admin.sso; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.system.controller.admin.oauth2.vo.open.OAuth2OpenAccessTokenRespVO; +import com.viewsh.module.system.controller.admin.sso.vo.SsoCallbackReqVO; +import com.viewsh.module.system.convert.oauth2.OAuth2OpenConvert; +import com.viewsh.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO; +import com.viewsh.module.system.enums.oauth2.OAuth2GrantTypeEnum; +import com.viewsh.module.system.service.oauth2.OAuth2ClientService; +import com.viewsh.module.system.service.oauth2.OAuth2GrantService; +import cn.hutool.core.util.StrUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; +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.RestController; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * SSO 回调接口 + * + * 面向「接入方前端」:业务平台、物联运维平台等内部子系统。 + * 子系统前端拿到 code 之后,调用本接口换取 access_token。 + * + * 设计要点: + * 1. 本接口是内部 SSO 流程,OAuth2 Server 和接入方属于同一套后端, + * 不需要传/校验 client_secret(密钥仅存数据库,由后台管理界面维护)。 + * 2. 安全性保证来自 OAuth2 框架本身: + * - code 一次性、短期有效,生成时绑定 client_id + redirect_uri + userId + * - redirect_uri 必须匹配客户端的白名单(validOAuthClientFromCache 会校验) + * - grant_type 必须是 authorization_code + * + * 场景示例(业务平台 → IoT 平台): + * 1. 用户已登录业务平台 + * 2. 业务前端调用 POST /system/oauth2/authorize?client_id=iot&auto_approve=true&... + * 拿到带 code 的 redirect URL + * 3. 浏览器重定向至 iot.xxx.com/sso-callback?code=xxx + * 4. IoT 前端调用本接口(clientId=iot, code=xxx, redirectUri=...)换取 token + * 5. IoT 前端存储 token,进入主界面 + * + * 反向(IoT → 业务平台)同理,改 clientId=biz 即可(原 yudao 默认 default 客户端已改名为 biz)。 + */ +@Tag(name = "管理后台 - SSO 回调") +@RestController +@RequestMapping("/system/sso") +@Validated +@Slf4j +public class SsoCallbackController { + + @Resource + private OAuth2GrantService oauth2GrantService; + @Resource + private OAuth2ClientService oauth2ClientService; + + @PostMapping("/callback") + @PermitAll + @Operation(summary = "SSO 授权码回调换 Token", + description = "前端在 sso-callback 路由拿到 code 后,调此接口换 access_token。" + + "client_secret 不需要传,由 OAuth2 框架通过 client_id + redirect_uri + code 的绑定关系保证安全。" + + "参数走 body 而非 query,避免 code 出现在 nginx access log / 浏览器历史。") + public CommonResult callback(@Valid @RequestBody SsoCallbackReqVO reqVO) { + // code/state 是半机密凭证:code 截断打印便于排障;state 只打印是否存在,不暴露具体值 + log.info("[callback][SSO 回调 clientId={} code={} redirectUri={} stateLen={}]", + reqVO.getClientId(), maskCode(reqVO.getCode()), reqVO.getRedirectUri(), + reqVO.getState() == null ? 0 : reqVO.getState().length()); + + // 1. 校验客户端的 grant_type 和 redirect_uri 白名单(不校验 secret,secret 仅存 DB) + oauth2ClientService.validOAuthClientFromCache( + reqVO.getClientId(), null, + OAuth2GrantTypeEnum.AUTHORIZATION_CODE.getGrantType(), + null, reqVO.getRedirectUri()); + + // 2. 用 code 换 access_token(一次性,换完 code 失效) + // state 会和生成 code 时记录的值比对,必须传回原始 state + OAuth2AccessTokenDO accessTokenDO = oauth2GrantService + .grantAuthorizationCodeForAccessToken( + reqVO.getClientId(), reqVO.getCode(), + reqVO.getRedirectUri(), reqVO.getState()); + + // 3. 复用 OAuth2OpenConvert 转为 VO 返回 + return success(OAuth2OpenConvert.INSTANCE.convert(accessTokenDO)); + } + + /** + * 授权码脱敏:只保留前 6 位 + 后 2 位,中间打 *** + */ + private static String maskCode(String code) { + if (StrUtil.isBlank(code)) { + return ""; + } + if (code.length() <= 8) { + return "***"; + } + return code.substring(0, 6) + "***" + code.substring(code.length() - 2); + } + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/sso/vo/SsoCallbackReqVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/sso/vo/SsoCallbackReqVO.java new file mode 100644 index 00000000..f4497c60 --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/sso/vo/SsoCallbackReqVO.java @@ -0,0 +1,32 @@ +package com.viewsh.module.system.controller.admin.sso.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Schema(description = "管理后台 - SSO 回调换 Token Request VO") +@Data +public class SsoCallbackReqVO { + + @Schema(description = "客户端编号(biz=业务平台 / iot=物联运维平台)", + requiredMode = Schema.RequiredMode.REQUIRED, example = "iot") + @NotBlank(message = "clientId 不能为空") + private String clientId; + + @Schema(description = "授权码(一次性,换完即失效)", + requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123") + @NotBlank(message = "code 不能为空") + private String code; + + @Schema(description = "重定向 URI,必须与发起 authorize 时一致", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "http://localhost:5667/sso-callback") + @NotBlank(message = "redirectUri 不能为空") + private String redirectUri; + + @Schema(description = "发起授权时的随机串,CSRF 防护 + 一致性校验", + requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123") + @NotBlank(message = "state 不能为空") + private String state; + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/oauth2/OAuth2ClientDO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/oauth2/OAuth2ClientDO.java index 529666ff..284f7878 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/oauth2/OAuth2ClientDO.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/oauth2/OAuth2ClientDO.java @@ -105,5 +105,12 @@ public class OAuth2ClientDO extends BaseDO { * 附加信息,JSON 格式 */ private String additionalInformation; + /** + * 平台标识,决定该客户端登录后能看到的菜单子集 + * + * 例如:{@code biz} 业务平台 / {@code iot} 物联运维平台。NULL 表示不做按客户端的菜单过滤。 + * 与 {@link com.viewsh.module.system.dal.dataobject.permission.MenuDO#getPlatform()} 对齐。 + */ + private String platform; } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/permission/MenuDO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/permission/MenuDO.java index d9ba093f..5af778de 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/permission/MenuDO.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/permission/MenuDO.java @@ -105,5 +105,12 @@ public class MenuDO extends BaseDO { * 如果为 false 时,当该菜单只有一个子菜单时,不展示自己,直接展示子菜单 */ private Boolean alwaysShow; + /** + * 平台标识:biz-业务平台,iot-物联运维平台,NULL-两个平台都展示 + * + * 与 {@link com.viewsh.module.system.dal.dataobject.oauth2.OAuth2ClientDO#getPlatform()} 对齐: + * 用户登录的 OAuth2 客户端的 platform 等于本字段,或本字段为 NULL,则该菜单展示。 + */ + private String platform; } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthServiceImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthServiceImpl.java index 521ae30c..504b7d2e 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthServiceImpl.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/auth/AdminAuthServiceImpl.java @@ -1,361 +1,379 @@ -package com.viewsh.module.system.service.auth; - -import cn.hutool.core.util.ObjectUtil; -import com.viewsh.framework.common.enums.CommonStatusEnum; -import com.viewsh.framework.common.enums.UserTypeEnum; -import com.viewsh.framework.common.util.monitor.TracerUtils; -import com.viewsh.framework.common.util.object.BeanUtils; -import com.viewsh.framework.common.util.servlet.ServletUtils; -import com.viewsh.framework.common.util.validation.ValidationUtils; -import com.viewsh.framework.datapermission.core.annotation.DataPermission; -import com.viewsh.module.system.api.logger.dto.LoginLogCreateReqDTO; -import com.viewsh.module.system.api.sms.SmsCodeApi; -import com.viewsh.module.system.api.sms.dto.code.SmsCodeUseReqDTO; -import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; -import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; -import com.viewsh.module.system.controller.admin.auth.vo.*; -import com.viewsh.module.system.convert.auth.AuthConvert; -import com.viewsh.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO; -import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; -import com.viewsh.module.system.enums.logger.LoginLogTypeEnum; -import com.viewsh.module.system.enums.logger.LoginResultEnum; -import com.viewsh.module.system.enums.oauth2.OAuth2ClientConstants; -import com.viewsh.module.system.enums.sms.SmsSceneEnum; -import com.viewsh.module.system.service.logger.LoginLogService; -import com.viewsh.module.system.service.member.MemberService; -import com.viewsh.module.system.service.social.SocialClientService; -import com.viewsh.module.system.service.oauth2.OAuth2TokenService; -import com.viewsh.module.system.service.social.SocialUserService; -import com.viewsh.module.system.service.user.AdminUserService; -import com.anji.captcha.model.common.ResponseModel; -import com.anji.captcha.model.vo.CaptchaVO; -import com.anji.captcha.service.CaptchaService; -import com.google.common.annotations.VisibleForTesting; -import jakarta.annotation.Resource; -import jakarta.validation.Validator; -import lombok.Setter; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo; -import cn.hutool.core.lang.Assert; -import com.viewsh.module.system.enums.social.SocialTypeEnum; - -import java.util.Objects; - -import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; -import static com.viewsh.framework.common.util.servlet.ServletUtils.getClientIP; -import static com.viewsh.module.system.enums.ErrorCodeConstants.*; - -/** - * Auth Service 实现类 - * - * @author 芋道源码 - */ -@Service -@Slf4j -public class AdminAuthServiceImpl implements AdminAuthService { - - @Resource - private AdminUserService userService; - @Resource - private LoginLogService loginLogService; - @Resource - private OAuth2TokenService oauth2TokenService; - @Resource - private SocialUserService socialUserService; - @Resource - private MemberService memberService; - @Resource - private SocialClientService socialClientService; - @Resource - private Validator validator; - @Resource - private CaptchaService captchaService; - @Resource - private SmsCodeApi smsCodeApi; - - /** - * 验证码的开关,默认为 true - */ - @Value("${viewsh.captcha.enable:true}") - @Setter // 为了单测:开启或者关闭验证码 - private Boolean captchaEnable; - - @Override - public AdminUserDO authenticate(String username, String password) { - final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME; - // 校验账号是否存在 - AdminUserDO user = userService.getUserByUsername(username); - if (user == null) { - createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); - throw exception(AUTH_LOGIN_BAD_CREDENTIALS); - } - if (!userService.isPasswordMatch(password, user.getPassword())) { - createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); - throw exception(AUTH_LOGIN_BAD_CREDENTIALS); - } - // 校验是否禁用 - if (CommonStatusEnum.isDisable(user.getStatus())) { - createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED); - throw exception(AUTH_LOGIN_USER_DISABLED); - } - return user; - } - - @Override - @DataPermission(enable = false) - public AuthLoginRespVO login(AuthLoginReqVO reqVO) { - // 校验验证码 - validateCaptcha(reqVO); - - // 使用账号密码,进行登录 - AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword()); - - // 如果 socialType 非空,说明需要绑定社交用户 - if (reqVO.getSocialType() != null) { - socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(), - reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState())); - } - // 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); - } - - @Override - public void sendSmsCode(AuthSmsSendReqVO reqVO) { - // 如果是重置密码场景,需要校验图形验证码是否正确 - if (Objects.equals(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene(), reqVO.getScene())) { - ResponseModel response = doValidateCaptcha(reqVO); - if (!response.isSuccess()) { - throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg()); - } - } - - // 登录场景,验证是否存在 - if (userService.getUserByMobile(reqVO.getMobile()) == null) { - throw exception(AUTH_MOBILE_NOT_EXISTS); - } - // 发送验证码 - smsCodeApi.sendSmsCode(AuthConvert.INSTANCE.convert(reqVO).setCreateIp(getClientIP())); - } - - @Override - public AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO) { - // 校验验证码 - smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), getClientIP())).checkError(); - - // 获得用户信息 - AdminUserDO user = userService.getUserByMobile(reqVO.getMobile()); - if (user == null) { - throw exception(USER_NOT_EXISTS); - } - - // 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE); - } - - private void createLoginLog(Long userId, String username, - LoginLogTypeEnum logTypeEnum, LoginResultEnum loginResult) { - // 插入登录日志 - LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); - reqDTO.setLogType(logTypeEnum.getType()); - reqDTO.setTraceId(TracerUtils.getTraceId()); - reqDTO.setUserId(userId); - reqDTO.setUserType(getUserType().getValue()); - reqDTO.setUsername(username); - reqDTO.setUserAgent(ServletUtils.getUserAgent()); - reqDTO.setUserIp(ServletUtils.getClientIP()); - reqDTO.setResult(loginResult.getResult()); - loginLogService.createLoginLog(reqDTO); - // 更新最后登录时间 - if (userId != null && Objects.equals(LoginResultEnum.SUCCESS.getResult(), loginResult.getResult())) { - userService.updateUserLogin(userId, ServletUtils.getClientIP()); - } - } - - @Override - public AuthLoginRespVO socialLogin(AuthSocialLoginReqVO reqVO) { - // 使用 code 授权码,进行登录。然后,获得到绑定的用户编号 - SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(UserTypeEnum.ADMIN.getValue(), reqVO.getType(), - reqVO.getCode(), reqVO.getState()); - if (socialUser == null || socialUser.getUserId() == null) { - throw exception(AUTH_THIRD_LOGIN_NOT_BIND); - } - - // 获得用户 - AdminUserDO user = userService.getUser(socialUser.getUserId()); - if (user == null) { - throw exception(USER_NOT_EXISTS); - } - - // 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public AuthLoginRespVO weixinMiniAppLogin(AuthWeixinMiniAppLoginReqVO reqVO) { - // 1. 通过 phoneCode 获取手机号 - WxMaPhoneNumberInfo phoneNumberInfo = socialClientService.getWxMaPhoneNumberInfo( - UserTypeEnum.ADMIN.getValue(), reqVO.getPhoneCode()); - Assert.notNull(phoneNumberInfo, "获得手机信息失败,结果为空"); - String mobile = phoneNumberInfo.getPurePhoneNumber(); - - // 2. 通过手机号查找管理员 - AdminUserDO user = userService.getUserByMobile(mobile); - if (user == null) { - throw exception(AUTH_WEIXIN_MINI_APP_PHONE_NOT_FOUND); - } - if (CommonStatusEnum.isDisable(user.getStatus())) { - throw exception(AUTH_LOGIN_USER_DISABLED); - } - - // 3. 通过 loginCode 获取社交用户(含 openid) - SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode( - UserTypeEnum.ADMIN.getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(), - reqVO.getLoginCode(), reqVO.getState()); - Assert.notNull(socialUser, "社交用户不能为空"); - - // 4. 绑定冲突检测 - // 4a. 当前 openid 是否已绑定其他管理员 - if (socialUser.getUserId() != null && !socialUser.getUserId().equals(user.getId())) { - throw exception(AUTH_WEIXIN_MINI_APP_WECHAT_BINDTO_OTHER); - } - // 4b. 目标管理员是否已绑定其他微信 - SocialUserRespDTO existByUser = socialUserService.getSocialUserByUserId( - UserTypeEnum.ADMIN.getValue(), user.getId(), - SocialTypeEnum.WECHAT_MINI_PROGRAM.getType()); - if (existByUser != null && !existByUser.getOpenid().equals(socialUser.getOpenid())) { - throw exception(AUTH_WEIXIN_MINI_APP_BINDTO_OTHER_WECHAT); - } - - // 5. 绑定社交用户(无冲突且未绑定时) - if (existByUser == null) { - socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), - getUserType().getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(), - reqVO.getLoginCode(), reqVO.getState())); - } - - // 6. 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL); - } - - @VisibleForTesting - void validateCaptcha(AuthLoginReqVO reqVO) { - ResponseModel response = doValidateCaptcha(reqVO); - // 校验验证码 - if (!response.isSuccess()) { - // 创建登录失败日志(验证码不正确) - createLoginLog(null, reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.CAPTCHA_CODE_ERROR); - throw exception(AUTH_LOGIN_CAPTCHA_CODE_ERROR, response.getRepMsg()); - } - } - - private ResponseModel doValidateCaptcha(CaptchaVerificationReqVO reqVO) { - // 如果验证码关闭,则不进行校验 - if (!captchaEnable) { - return ResponseModel.success(); - } - ValidationUtils.validate(validator, reqVO, CaptchaVerificationReqVO.CodeEnableGroup.class); - CaptchaVO captchaVO = new CaptchaVO(); - captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification()); - return captchaService.verification(captchaVO); - } - - private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) { - // 插入登陆日志 - createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS); - // 创建访问令牌 - OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(), - OAuth2ClientConstants.CLIENT_ID_DEFAULT, null); - // 构建返回结果 - return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class); - } - - @Override - public AuthLoginRespVO refreshToken(String refreshToken) { - OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, OAuth2ClientConstants.CLIENT_ID_DEFAULT); - return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class); - } - - @Override - public void logout(String token, Integer logType) { - // 删除访问令牌 - OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.removeAccessToken(token); - if (accessTokenDO == null) { - return; - } - // 删除成功,则记录登出日志 - createLogoutLog(accessTokenDO.getUserId(), accessTokenDO.getUserType(), logType); - } - - private void createLogoutLog(Long userId, Integer userType, Integer logType) { - LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); - reqDTO.setLogType(logType); - reqDTO.setTraceId(TracerUtils.getTraceId()); - reqDTO.setUserId(userId); - reqDTO.setUserType(userType); - if (ObjectUtil.equal(getUserType().getValue(), userType)) { - reqDTO.setUsername(getUsername(userId)); - } else { - reqDTO.setUsername(memberService.getMemberUserMobile(userId)); - } - reqDTO.setUserAgent(ServletUtils.getUserAgent()); - reqDTO.setUserIp(ServletUtils.getClientIP()); - reqDTO.setResult(LoginResultEnum.SUCCESS.getResult()); - loginLogService.createLoginLog(reqDTO); - } - - private String getUsername(Long userId) { - if (userId == null) { - return null; - } - AdminUserDO user = userService.getUser(userId); - return user != null ? user.getUsername() : null; - } - - private UserTypeEnum getUserType() { - return UserTypeEnum.ADMIN; - } - - @Override - public AuthLoginRespVO register(AuthRegisterReqVO registerReqVO) { - // 1. 校验验证码 - validateCaptcha(registerReqVO); - - // 2. 校验用户名是否已存在 - Long userId = userService.registerUser(registerReqVO); - - // 3. 创建 Token 令牌,记录登录日志 - return createTokenAfterLoginSuccess(userId, registerReqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); - } - - @VisibleForTesting - void validateCaptcha(AuthRegisterReqVO reqVO) { - ResponseModel response = doValidateCaptcha(reqVO); - // 验证不通过 - if (!response.isSuccess()) { - throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg()); - } - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void resetPassword(AuthResetPasswordReqVO reqVO) { - AdminUserDO userByMobile = userService.getUserByMobile(reqVO.getMobile()); - if (userByMobile == null) { - throw exception(USER_MOBILE_NOT_EXISTS); - } - - smsCodeApi.useSmsCode(new SmsCodeUseReqDTO() - .setCode(reqVO.getCode()) - .setMobile(reqVO.getMobile()) - .setScene(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene()) - .setUsedIp(getClientIP()) - ).checkError(); - - userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword()); - } -} +package com.viewsh.module.system.service.auth; + +import cn.hutool.core.util.ObjectUtil; +import com.viewsh.framework.common.enums.CommonStatusEnum; +import com.viewsh.framework.common.enums.UserTypeEnum; +import com.viewsh.framework.common.util.monitor.TracerUtils; +import com.viewsh.framework.common.util.object.BeanUtils; +import com.viewsh.framework.common.util.servlet.ServletUtils; +import com.viewsh.framework.common.util.validation.ValidationUtils; +import com.viewsh.framework.web.core.util.WebFrameworkUtils; +import cn.hutool.core.util.StrUtil; +import jakarta.servlet.http.HttpServletRequest; +import com.viewsh.framework.datapermission.core.annotation.DataPermission; +import com.viewsh.module.system.api.logger.dto.LoginLogCreateReqDTO; +import com.viewsh.module.system.api.sms.SmsCodeApi; +import com.viewsh.module.system.api.sms.dto.code.SmsCodeUseReqDTO; +import com.viewsh.module.system.api.social.dto.SocialUserBindReqDTO; +import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; +import com.viewsh.module.system.controller.admin.auth.vo.*; +import com.viewsh.module.system.convert.auth.AuthConvert; +import com.viewsh.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO; +import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; +import com.viewsh.module.system.enums.logger.LoginLogTypeEnum; +import com.viewsh.module.system.enums.logger.LoginResultEnum; +import com.viewsh.module.system.enums.oauth2.OAuth2ClientConstants; +import com.viewsh.module.system.enums.sms.SmsSceneEnum; +import com.viewsh.module.system.service.logger.LoginLogService; +import com.viewsh.module.system.service.member.MemberService; +import com.viewsh.module.system.service.social.SocialClientService; +import com.viewsh.module.system.service.oauth2.OAuth2TokenService; +import com.viewsh.module.system.service.social.SocialUserService; +import com.viewsh.module.system.service.user.AdminUserService; +import com.anji.captcha.model.common.ResponseModel; +import com.anji.captcha.model.vo.CaptchaVO; +import com.anji.captcha.service.CaptchaService; +import com.google.common.annotations.VisibleForTesting; +import jakarta.annotation.Resource; +import jakarta.validation.Validator; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo; +import cn.hutool.core.lang.Assert; +import com.viewsh.module.system.enums.social.SocialTypeEnum; + +import java.util.Objects; + +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.framework.common.util.servlet.ServletUtils.getClientIP; +import static com.viewsh.module.system.enums.ErrorCodeConstants.*; + +/** + * Auth Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Slf4j +public class AdminAuthServiceImpl implements AdminAuthService { + + @Resource + private AdminUserService userService; + @Resource + private LoginLogService loginLogService; + @Resource + private OAuth2TokenService oauth2TokenService; + @Resource + private SocialUserService socialUserService; + @Resource + private MemberService memberService; + @Resource + private SocialClientService socialClientService; + @Resource + private Validator validator; + @Resource + private CaptchaService captchaService; + @Resource + private SmsCodeApi smsCodeApi; + + /** + * 验证码的开关,默认为 true + */ + @Value("${viewsh.captcha.enable:true}") + @Setter // 为了单测:开启或者关闭验证码 + private Boolean captchaEnable; + + @Override + public AdminUserDO authenticate(String username, String password) { + final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME; + // 校验账号是否存在 + AdminUserDO user = userService.getUserByUsername(username); + if (user == null) { + createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); + throw exception(AUTH_LOGIN_BAD_CREDENTIALS); + } + if (!userService.isPasswordMatch(password, user.getPassword())) { + createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); + throw exception(AUTH_LOGIN_BAD_CREDENTIALS); + } + // 校验是否禁用 + if (CommonStatusEnum.isDisable(user.getStatus())) { + createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED); + throw exception(AUTH_LOGIN_USER_DISABLED); + } + return user; + } + + @Override + @DataPermission(enable = false) + public AuthLoginRespVO login(AuthLoginReqVO reqVO) { + // 校验验证码 + validateCaptcha(reqVO); + + // 使用账号密码,进行登录 + AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword()); + + // 如果 socialType 非空,说明需要绑定社交用户 + if (reqVO.getSocialType() != null) { + socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(), + reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState())); + } + // 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); + } + + @Override + public void sendSmsCode(AuthSmsSendReqVO reqVO) { + // 如果是重置密码场景,需要校验图形验证码是否正确 + if (Objects.equals(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene(), reqVO.getScene())) { + ResponseModel response = doValidateCaptcha(reqVO); + if (!response.isSuccess()) { + throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg()); + } + } + + // 登录场景,验证是否存在 + if (userService.getUserByMobile(reqVO.getMobile()) == null) { + throw exception(AUTH_MOBILE_NOT_EXISTS); + } + // 发送验证码 + smsCodeApi.sendSmsCode(AuthConvert.INSTANCE.convert(reqVO).setCreateIp(getClientIP())); + } + + @Override + public AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO) { + // 校验验证码 + smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), getClientIP())).checkError(); + + // 获得用户信息 + AdminUserDO user = userService.getUserByMobile(reqVO.getMobile()); + if (user == null) { + throw exception(USER_NOT_EXISTS); + } + + // 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE); + } + + private void createLoginLog(Long userId, String username, + LoginLogTypeEnum logTypeEnum, LoginResultEnum loginResult) { + // 插入登录日志 + LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); + reqDTO.setLogType(logTypeEnum.getType()); + reqDTO.setTraceId(TracerUtils.getTraceId()); + reqDTO.setUserId(userId); + reqDTO.setUserType(getUserType().getValue()); + reqDTO.setUsername(username); + reqDTO.setUserAgent(ServletUtils.getUserAgent()); + reqDTO.setUserIp(ServletUtils.getClientIP()); + reqDTO.setResult(loginResult.getResult()); + loginLogService.createLoginLog(reqDTO); + // 更新最后登录时间 + if (userId != null && Objects.equals(LoginResultEnum.SUCCESS.getResult(), loginResult.getResult())) { + userService.updateUserLogin(userId, ServletUtils.getClientIP()); + } + } + + @Override + public AuthLoginRespVO socialLogin(AuthSocialLoginReqVO reqVO) { + // 使用 code 授权码,进行登录。然后,获得到绑定的用户编号 + SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(UserTypeEnum.ADMIN.getValue(), reqVO.getType(), + reqVO.getCode(), reqVO.getState()); + if (socialUser == null || socialUser.getUserId() == null) { + throw exception(AUTH_THIRD_LOGIN_NOT_BIND); + } + + // 获得用户 + AdminUserDO user = userService.getUser(socialUser.getUserId()); + if (user == null) { + throw exception(USER_NOT_EXISTS); + } + + // 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public AuthLoginRespVO weixinMiniAppLogin(AuthWeixinMiniAppLoginReqVO reqVO) { + // 1. 通过 phoneCode 获取手机号 + WxMaPhoneNumberInfo phoneNumberInfo = socialClientService.getWxMaPhoneNumberInfo( + UserTypeEnum.ADMIN.getValue(), reqVO.getPhoneCode()); + Assert.notNull(phoneNumberInfo, "获得手机信息失败,结果为空"); + String mobile = phoneNumberInfo.getPurePhoneNumber(); + + // 2. 通过手机号查找管理员 + AdminUserDO user = userService.getUserByMobile(mobile); + if (user == null) { + throw exception(AUTH_WEIXIN_MINI_APP_PHONE_NOT_FOUND); + } + if (CommonStatusEnum.isDisable(user.getStatus())) { + throw exception(AUTH_LOGIN_USER_DISABLED); + } + + // 3. 通过 loginCode 获取社交用户(含 openid) + SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode( + UserTypeEnum.ADMIN.getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(), + reqVO.getLoginCode(), reqVO.getState()); + Assert.notNull(socialUser, "社交用户不能为空"); + + // 4. 绑定冲突检测 + // 4a. 当前 openid 是否已绑定其他管理员 + if (socialUser.getUserId() != null && !socialUser.getUserId().equals(user.getId())) { + throw exception(AUTH_WEIXIN_MINI_APP_WECHAT_BINDTO_OTHER); + } + // 4b. 目标管理员是否已绑定其他微信 + SocialUserRespDTO existByUser = socialUserService.getSocialUserByUserId( + UserTypeEnum.ADMIN.getValue(), user.getId(), + SocialTypeEnum.WECHAT_MINI_PROGRAM.getType()); + if (existByUser != null && !existByUser.getOpenid().equals(socialUser.getOpenid())) { + throw exception(AUTH_WEIXIN_MINI_APP_BINDTO_OTHER_WECHAT); + } + + // 5. 绑定社交用户(无冲突且未绑定时) + if (existByUser == null) { + socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), + getUserType().getValue(), SocialTypeEnum.WECHAT_MINI_PROGRAM.getType(), + reqVO.getLoginCode(), reqVO.getState())); + } + + // 6. 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL); + } + + @VisibleForTesting + void validateCaptcha(AuthLoginReqVO reqVO) { + ResponseModel response = doValidateCaptcha(reqVO); + // 校验验证码 + if (!response.isSuccess()) { + // 创建登录失败日志(验证码不正确) + createLoginLog(null, reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.CAPTCHA_CODE_ERROR); + throw exception(AUTH_LOGIN_CAPTCHA_CODE_ERROR, response.getRepMsg()); + } + } + + private ResponseModel doValidateCaptcha(CaptchaVerificationReqVO reqVO) { + // 如果验证码关闭,则不进行校验 + if (!captchaEnable) { + return ResponseModel.success(); + } + ValidationUtils.validate(validator, reqVO, CaptchaVerificationReqVO.CodeEnableGroup.class); + CaptchaVO captchaVO = new CaptchaVO(); + captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification()); + return captchaService.verification(captchaVO); + } + + private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) { + // 插入登陆日志 + createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS); + // 创建访问令牌(client_id 优先取请求头 X-Client-Id,缺省回退 default) + OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(), + resolveClientId(), null); + // 构建返回结果 + return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class); + } + + @Override + public AuthLoginRespVO refreshToken(String refreshToken) { + OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, resolveClientId()); + return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class); + } + + /** + * 解析当前请求声明的 OAuth2 客户端编号。 + * + * 用于密码登录、refresh-token 等"还没有 token 的入口"——前端通过请求头 X-Client-Id 声明自己身份, + * 决定 token 绑定到哪个客户端,进而影响 {@code AuthController#getPermissionInfo} 按 platform 过滤的菜单。 + * + * 缺省回退到 {@link OAuth2ClientConstants#CLIENT_ID_DEFAULT}("default"),即原生业务平台客户端; + * 在 DB 层通过 system_oauth2_client.platform='biz' 标识。 + */ + private String resolveClientId() { + HttpServletRequest request = WebFrameworkUtils.getRequest(); + String clientId = WebFrameworkUtils.getClientId(request); + return StrUtil.isNotBlank(clientId) ? clientId : OAuth2ClientConstants.CLIENT_ID_DEFAULT; + } + + @Override + public void logout(String token, Integer logType) { + // 删除访问令牌 + OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.removeAccessToken(token); + if (accessTokenDO == null) { + return; + } + // 删除成功,则记录登出日志 + createLogoutLog(accessTokenDO.getUserId(), accessTokenDO.getUserType(), logType); + } + + private void createLogoutLog(Long userId, Integer userType, Integer logType) { + LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); + reqDTO.setLogType(logType); + reqDTO.setTraceId(TracerUtils.getTraceId()); + reqDTO.setUserId(userId); + reqDTO.setUserType(userType); + if (ObjectUtil.equal(getUserType().getValue(), userType)) { + reqDTO.setUsername(getUsername(userId)); + } else { + reqDTO.setUsername(memberService.getMemberUserMobile(userId)); + } + reqDTO.setUserAgent(ServletUtils.getUserAgent()); + reqDTO.setUserIp(ServletUtils.getClientIP()); + reqDTO.setResult(LoginResultEnum.SUCCESS.getResult()); + loginLogService.createLoginLog(reqDTO); + } + + private String getUsername(Long userId) { + if (userId == null) { + return null; + } + AdminUserDO user = userService.getUser(userId); + return user != null ? user.getUsername() : null; + } + + private UserTypeEnum getUserType() { + return UserTypeEnum.ADMIN; + } + + @Override + public AuthLoginRespVO register(AuthRegisterReqVO registerReqVO) { + // 1. 校验验证码 + validateCaptcha(registerReqVO); + + // 2. 校验用户名是否已存在 + Long userId = userService.registerUser(registerReqVO); + + // 3. 创建 Token 令牌,记录登录日志 + return createTokenAfterLoginSuccess(userId, registerReqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME); + } + + @VisibleForTesting + void validateCaptcha(AuthRegisterReqVO reqVO) { + ResponseModel response = doValidateCaptcha(reqVO); + // 验证不通过 + if (!response.isSuccess()) { + throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void resetPassword(AuthResetPasswordReqVO reqVO) { + AdminUserDO userByMobile = userService.getUserByMobile(reqVO.getMobile()); + if (userByMobile == null) { + throw exception(USER_MOBILE_NOT_EXISTS); + } + + smsCodeApi.useSmsCode(new SmsCodeUseReqDTO() + .setCode(reqVO.getCode()) + .setMobile(reqVO.getMobile()) + .setScene(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene()) + .setUsedIp(getClientIP()) + ).checkError(); + + userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword()); + } +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuService.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuService.java index 96b45ab4..40d20287 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuService.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuService.java @@ -67,6 +67,15 @@ public interface MenuService { */ List filterDisableMenus(List list); + /** + * 按平台标识过滤菜单。保留 platform 为空(全平台共用)或与入参相同的菜单 + * + * @param list 菜单列表 + * @param platform 平台标识(来自 OAuth2 客户端的 platform 字段)。为空时不过滤,原样返回 + * @return 过滤后的菜单列表 + */ + List filterMenusByPlatform(List list, String platform); + /** * 筛选菜单列表 * diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuServiceImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuServiceImpl.java index b9376222..a03c0432 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuServiceImpl.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/permission/MenuServiceImpl.java @@ -136,6 +136,20 @@ public class MenuServiceImpl implements MenuService { return menus; } + @Override + public List filterMenusByPlatform(List menuList, String platform) { + if (CollUtil.isEmpty(menuList) || StrUtil.isBlank(platform)) { + return menuList; + } + List result = new ArrayList<>(menuList.size()); + for (MenuDO menu : menuList) { + if (StrUtil.isBlank(menu.getPlatform()) || platform.equals(menu.getPlatform())) { + result.add(menu); + } + } + return result; + } + @Override public List filterDisableMenus(List menuList) { if (CollUtil.isEmpty(menuList)){ diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectService.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectService.java index 41aa030d..10fe7976 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectService.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectService.java @@ -118,4 +118,16 @@ public interface ProjectService { */ List getAuthorizedEnabledProjects(Long userId); + /** + * 单项校验:该用户是否有权访问该项目 + * + * 相比 {@link #getAuthorizedProjectIds(Long)} 取全量再 contains, + * 此方法对超管直接返回 true、对普通用户走主键索引单行查询,显著降低 Filter 每请求开销。 + * + * @param userId 用户编号 + * @param projectId 项目编号 + * @return 是否授权 + */ + boolean isProjectAuthorized(Long userId, Long projectId); + } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectServiceImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectServiceImpl.java index 733c6303..17f9fedf 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectServiceImpl.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectServiceImpl.java @@ -12,6 +12,8 @@ import com.viewsh.module.system.dal.dataobject.project.UserProjectDO; import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; import com.viewsh.module.system.dal.mysql.project.ProjectMapper; import com.viewsh.module.system.dal.mysql.project.UserProjectMapper; +import com.viewsh.module.system.service.permission.PermissionService; +import com.viewsh.module.system.service.permission.RoleService; import com.viewsh.module.system.service.user.AdminUserService; import jakarta.annotation.Resource; import org.springframework.context.annotation.Lazy; @@ -21,6 +23,7 @@ import org.springframework.validation.annotation.Validated; import java.util.Collections; import java.util.List; +import java.util.Set; import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; import static com.viewsh.module.system.enums.ErrorCodeConstants.*; @@ -44,6 +47,14 @@ public class ProjectServiceImpl implements ProjectService { @Lazy // 延迟,避免循环依赖报错 private AdminUserService userService; + @Resource + @Lazy + private PermissionService permissionService; + + @Resource + @Lazy + private RoleService roleService; + @Override public Long createProject(ProjectSaveReqVO createReqVO) { // 校验项目名称是否重复 @@ -148,10 +159,33 @@ public class ProjectServiceImpl implements ProjectService { @Override public List getAuthorizedProjectIds(Long userId) { + // 超管绕过 user_project 绑定校验:直接返回当前租户下所有项目 ID + // 与 TenantSecurityWebFilter 对超管的处理语义一致 + if (isSuperAdmin(userId)) { + return CollectionUtils.convertList(projectMapper.selectList(), ProjectDO::getId); + } List userProjects = userProjectMapper.selectListByUserId(userId); return CollectionUtils.convertList(userProjects, UserProjectDO::getProjectId); } + /** + * 判断用户是否为超级管理员 + * + * 注意:必须使用 FromCache 版本,本方法被 ProjectSecurityWebFilter 每次请求都会调用, + * 走无缓存的 getUserRoleIdListByUserId 会直接把 system_user_role 打爆, + * 典型症状是 /admin-api/** 接口(如 get-permission-info)大面积超时。 + */ + private boolean isSuperAdmin(Long userId) { + if (userId == null) { + return false; + } + Set roleIds = permissionService.getUserRoleIdListByUserIdFromCache(userId); + if (CollUtil.isEmpty(roleIds)) { + return false; + } + return roleService.hasAnySuperAdmin(roleIds); + } + @Override public List getAuthorizedEnabledProjects(Long userId) { List authorizedIds = getAuthorizedProjectIds(userId); @@ -174,17 +208,35 @@ public class ProjectServiceImpl implements ProjectService { if (authorizedProjectIds.size() == 1) { return authorizedProjectIds.get(0); } - // 2. 查找 DEFAULT 项目 - for (Long projectId : authorizedProjectIds) { - ProjectDO project = projectMapper.selectById(projectId); - if (project != null && ProjectDO.CODE_DEFAULT.equals(project.getCode())) { + // 2. 一次批量查出所有候选项目(避免 N 次 selectById) + List projects = projectMapper.selectByIds(authorizedProjectIds); + // 2.1 查找 DEFAULT 编码的项目 + for (ProjectDO project : projects) { + if (ProjectDO.CODE_DEFAULT.equals(project.getCode())) { return project.getId(); } } - // 3. 取最小 ID + // 3. 退化到最小 ID return authorizedProjectIds.stream().min(Long::compareTo).orElse(null); } + @Override + public boolean isProjectAuthorized(Long userId, Long projectId) { + if (userId == null || projectId == null) { + return false; + } + // 超管直接放行,不查 user_project 表 + if (isSuperAdmin(userId)) { + return true; + } + // 普通用户走 (user_id, project_id) 唯一索引单行查询 + Long count = userProjectMapper.selectCount( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(UserProjectDO::getUserId, userId) + .eq(UserProjectDO::getProjectId, projectId)); + return count != null && count > 0; + } + private void validateProjectExists(Long id) { if (projectMapper.selectById(id) == null) { throw exception(PROJECT_NOT_EXISTS); diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/UserProjectService.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/UserProjectService.java index bc40ee59..805ee66a 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/UserProjectService.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/UserProjectService.java @@ -1,5 +1,9 @@ package com.viewsh.module.system.service.project; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.module.system.controller.admin.project.vo.UserProjectPageReqVO; +import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; + import java.util.Set; /** @@ -59,4 +63,29 @@ public interface UserProjectService { */ void deleteByUserId(Long userId); + /** + * 分页查询项目的成员列表,自动过滤超级管理员 + * 超管通过角色天然拥有所有项目,不应该出现在成员管理界面 + * + * @param reqVO 分页查询参数 + * @return 用户分页结果(返回 AdminUserDO 便于前端渲染用户名/昵称/部门) + */ + PageResult getProjectUserPage(UserProjectPageReqVO reqVO); + + /** + * 增量把一组用户加入到项目(已在的用户跳过,不影响现有绑定) + * + * @param projectId 项目编号 + * @param userIds 要新增的用户编号集合 + */ + void addProjectUsers(Long projectId, Set userIds); + + /** + * 从项目中移除单个成员(带超管/自踢守卫) + * + * @param projectId 项目编号 + * @param userId 用户编号 + */ + void removeProjectUser(Long projectId, Long userId); + } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/UserProjectServiceImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/UserProjectServiceImpl.java index c6b22d4a..c7babd40 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/UserProjectServiceImpl.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/UserProjectServiceImpl.java @@ -1,12 +1,18 @@ package com.viewsh.module.system.service.project; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.viewsh.framework.common.pojo.PageResult; import com.viewsh.framework.common.util.collection.CollectionUtils; +import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX; import com.viewsh.framework.security.core.util.SecurityFrameworkUtils; import com.viewsh.framework.tenant.core.context.ProjectContextHolder; +import com.viewsh.module.system.controller.admin.project.vo.UserProjectPageReqVO; import com.viewsh.module.system.dal.dataobject.project.UserProjectDO; +import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; import com.viewsh.module.system.dal.mysql.project.UserProjectMapper; +import com.viewsh.module.system.dal.mysql.user.AdminUserMapper; import com.viewsh.module.system.service.permission.PermissionService; import com.viewsh.module.system.service.permission.RoleService; import jakarta.annotation.Resource; @@ -15,6 +21,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -45,6 +52,9 @@ public class UserProjectServiceImpl implements UserProjectService { @Lazy private RoleService roleService; + @Resource + private AdminUserMapper userMapper; + @Override @Transactional(rollbackFor = Exception.class) public void assignUserProjects(Long userId, Set projectIds) { @@ -170,4 +180,97 @@ public class UserProjectServiceImpl implements UserProjectService { .eq(UserProjectDO::getUserId, userId)); } + @Override + public PageResult getProjectUserPage(UserProjectPageReqVO reqVO) { + // 1. 查项目下所有 userIds + Set memberIds = convertSet( + userProjectMapper.selectListByProjectId(reqVO.getProjectId()), + UserProjectDO::getUserId); + if (CollUtil.isEmpty(memberIds)) { + return PageResult.empty(); + } + + // 2. 过滤超管(超管靠角色天然拥有所有项目,不应出现在成员列表) + List filteredIds = new ArrayList<>(memberIds.size()); + for (Long uid : memberIds) { + if (!isSuperAdmin(uid)) { + filteredIds.add(uid); + } + } + if (CollUtil.isEmpty(filteredIds)) { + return PageResult.empty(); + } + + // 3. 按 ids + keyword 分页查 AdminUserDO + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .in(AdminUserDO::getId, filteredIds); + if (StrUtil.isNotBlank(reqVO.getKeyword())) { + String kw = reqVO.getKeyword(); + wrapper.and(q -> q.like(AdminUserDO::getUsername, kw) + .or().like(AdminUserDO::getNickname, kw) + .or().like(AdminUserDO::getMobile, kw)); + } + wrapper.orderByDesc(AdminUserDO::getId); + return userMapper.selectPage(reqVO, wrapper); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void addProjectUsers(Long projectId, Set userIds) { + Set target = CollUtil.emptyIfNull(userIds); + if (target.isEmpty()) { + return; + } + // 查当前已绑定,避免重复插入 + Set existing = convertSet( + userProjectMapper.selectListByProjectId(projectId), + UserProjectDO::getUserId); + Collection toInsert = CollUtil.subtract(target, existing); + if (CollUtil.isEmpty(toInsert)) { + return; + } + userProjectMapper.insertBatch(CollectionUtils.convertList(toInsert, uid -> { + UserProjectDO entity = new UserProjectDO(); + entity.setUserId(uid); + entity.setProjectId(projectId); + return entity; + })); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeProjectUser(Long projectId, Long userId) { + // 超管守卫:不让把超管从项目里踢掉(本身超管就不该在 user_project 里;即使被历史数据污染也保护) + if (isSuperAdmin(userId)) { + throw exception(USER_PROJECT_CANNOT_REMOVE_SUPER_ADMIN); + } + // 自踢守卫:当前登录人 && 当前项目 == 目标项目 → 拒绝 + Long currentLoginUserId = SecurityFrameworkUtils.getLoginUserId(); + Long currentProjectId = ProjectContextHolder.getProjectId(); + if (currentLoginUserId != null && currentLoginUserId.equals(userId) + && currentProjectId != null && currentProjectId.equals(projectId)) { + throw exception(USER_PROJECT_CANNOT_REMOVE_SELF_CURRENT); + } + userProjectMapper.delete(Wrappers.lambdaQuery() + .eq(UserProjectDO::getProjectId, projectId) + .eq(UserProjectDO::getUserId, userId)); + } + + /** + * 判断用户是否超管 + * + * 必须走 FromCache 版本,否则调用点(removeProjectUser / getProjectUserPage) + * 在高并发场景(比如批量移除成员)下会放大 DB 压力。 + */ + private boolean isSuperAdmin(Long userId) { + if (userId == null) { + return false; + } + Set roleIds = permissionService.getUserRoleIdListByUserIdFromCache(userId); + if (CollUtil.isEmpty(roleIds)) { + return false; + } + return roleService.hasAnySuperAdmin(roleIds); + } + }