p2_audit_placeholders.py 22.9 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
P2 审计脚本:把存量代码真正需要的 `${占位符}` 与「新工程提供的配置」对账。

## 为什么需要它

老工程的 `application*.properties` 是**按模块各一份**的(`zteits-park` / `zteits-order` /
`zteits-pay` / `zteits-charge` … 共十几份),而搬迁红线第 9 条「禁止修改
application-*.properties 中的环境配置」+ `tools/p2_import_legacy.py` 的策略,
决定了这些文件**故意不搬** —— 环境配置以新工程 `parking-server/src/main/resources/*.yaml` 为准。

于是必然出现缺口:存量代码里 `@Value("${xxx}")` 的**业务参数**(不是连接串)在新工程里没人提供,
启动报

    java.lang.IllegalArgumentException: Could not resolve placeholder 'xxx' in value "${xxx}"

而且**一次只报第一个** —— 靠启动试错要启 N 次。本脚本一次列全,并给出老工程里的原值。

## 三类假阳性(都是本脚本第一版踩过的坑,缺一不可)

| 假阳性 | 例子 | 为什么不是缺口 |
|---|---|---|
| 注解被注释掉了 | `AliOssClientConfig` 里 7 个 `ali.oss.*` 被 `/* */` 整段注释(实际改从数据库 `SysCodeValueCacheUtil` 读) | 代码里根本没有这个注解 |
| `@Value` 标在 **static** 字段上 | `Wechatpayapiv3Config` 的 4 个 `wechatpay.apiv3.*` | Spring **不会**注入 static 字段,值来自字段初始化表达式 |
| 类根本不是 Bean | 包不在启动类扫描范围内、或类上没有组件注解、或已被 `excludeFilters` 排除 | 容器里没有这个类,`@Value` 永远不会被解析 |

所以判定链是:**注解真的存在(去注释)→ 不是 static → 类会被注册成 Bean → 才是「必需」**。

## 扫描范围是怎么算出来的

启动类 `ParkingServerApplication` 的 `@ComponentScan.basePackages`(含 `${parking.info.base-package}`
这类占位符,用 `application.yaml` 里的值代换)+ **源码里嵌套的 `@ComponentScan`**(递归到不动点,
无参数的那种按「本类所在包」算)。这套计算同时也是对手写扫描列表的一次校验:
`--show-scan` 会把它解析出的包逐个打出来。

## 用法

    python3 tools/p2_audit_placeholders.py            # 只列「必需但缺失」与「有默认值需核对」
    python3 tools/p2_audit_placeholders.py --all      # 连「已提供」「判为无需配置」一起列出
    python3 tools/p2_audit_placeholders.py --emit /tmp/legacy-params.properties
                                                      # 生成可直接搬进 parking-server 的配置草稿
"""

import argparse
import csv
import glob
import os
import re
import sys
from collections import defaultdict

CLOUD = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LEGACY = "/Users/wangbiao/IdeaProjects/parking/zteits-clouds"

SCAN_DIRS = [
    "parking-server", "parking-framework", "parking-module-api", "parking-module-infra",
    "parking-module-system", "parking-module-park", "parking-module-order",
    "parking-module-pay", "parking-module-bill", "parking-module-charge",
    "parking-module-business",
]

APP_JAVA = "parking-server/src/main/java/com/zteits/parking/server/ParkingServerApplication.java"

VALUE_RE = re.compile(r'@Value\s*\(\s*(?:value\s*=\s*)?"\$\{([^}]*)\}"')
NESTED_RE = re.compile(r'\$\{([^}]*)\}')
PKG_RE = re.compile(r'^\s*package\s+([\w.]+)\s*;', re.M)
YAML_KV_RE = re.compile(r'^(\s*)([A-Za-z0-9_.\-]+):(\s|$)')
PROP_KV_RE = re.compile(r'^\s*([A-Za-z0-9_.\-]+)\s*[=:]\s*(.*)$')

STEREOTYPES = ("@Component", "@Configuration", "@Service", "@Repository", "@Controller",
               "@RestController", "@ConfigurationProperties")
SCAN_ANNO_RE = re.compile(
    r'@ComponentScans?\s*\('  # 只处理带参数的;无参数的单独处理
)

# 值像「凭据」的键 → 不把老工程的值写进仓库,改成环境变量占位
SECRET_HINTS = ("password", "passwd", "secret", "accesskey", "privatekey", "apiv3key",
                "token", "cert.pass", "appid", "private-key", "sdkkey", "mchkey")


# --------------------------------------------------------------------------- #
# 基础工具
# --------------------------------------------------------------------------- #
def strip_comments(src):
    """把注释内容换成空格(保留换行与偏移,行号仍然可用),字符串字面量原样保留。

    只处理 // 与 /* */;不处理字符串里的转义细节 —— 本脚本只关心 @Value("${...}"),
    这种精度足够。"""
    out = []
    i, n = 0, len(src)
    while i < n:
        c = src[i]
        if c == '"' or c == "'":  # 字符串/字符字面量,整段原样保留
            q = c
            out.append(c)
            i += 1
            while i < n:
                if src[i] == "\\":
                    out.append(src[i:i + 2])
                    i += 2
                    continue
                out.append(src[i])
                if src[i] == q:
                    i += 1
                    break
                i += 1
            continue
        if c == "/" and i + 1 < n and src[i + 1] == "/":
            while i < n and src[i] != "\n":
                out.append(" ")
                i += 1
            continue
        if c == "/" and i + 1 < n and src[i + 1] == "*":
            out.append("  ")
            i += 2
            while i + 1 < n and not (src[i] == "*" and src[i + 1] == "/"):
                out.append("\n" if src[i] == "\n" else " ")
                i += 1
            out.append("  ")
            i += 2
            continue
        out.append(c)
        i += 1
    return "".join(out)


def balanced(text, open_idx):
    """open_idx 指向 '(',返回配对的 ')' 之后的位置,以及括号内部文本。"""
    depth = 0
    for i in range(open_idx, len(text)):
        if text[i] == "(":
            depth += 1
        elif text[i] == ")":
            depth -= 1
            if depth == 0:
                return i + 1, text[open_idx + 1:i]
    return len(text), text[open_idx + 1:]


def brace_span(text, open_idx):
    """open_idx 指向 '{',配对着色后返回 (内部文本, 结束位置)。

    ⚠️ 不能用 `\\{(.*?)\\}` 这种正则:值里常有 `${...}` 占位符,
    **非贪婪匹配会在第一个 `}`(也就是 `${...}` 的闭合括号)就截断**,
    这个坑本脚本踩过 —— 表现为「明明有 basePackages 却一个包都没解析出来」。"""
    depth = 0
    for i in range(open_idx, len(text)):
        if text[i] == "{":
            depth += 1
        elif text[i] == "}":
            depth -= 1
            if depth == 0:
                return text[open_idx + 1:i], i + 1
    return text[open_idx + 1:], len(text)


def split_placeholder(expr):
    """`a:b` → ('a','b');`a` → ('a', None)。与 Spring 的 PlaceholderHelper 行为一致。"""
    if ":" in expr:
        k, v = expr.split(":", 1)
        return k.strip(), v
    return expr.strip(), None


# --------------------------------------------------------------------------- #
# 1) 新工程的配置提供方(yaml / properties)
# --------------------------------------------------------------------------- #
def flatten_yaml(path):
    """极简 YAML 展平:只处理「缩进 + key: value」和 `key:` 开块。手写规整缩进够用。"""
    out, stack = {}, []
    with open(path, encoding="utf-8", errors="replace") as fh:
        for line in fh:
            if not line.strip() or line.lstrip().startswith("#"):
                continue
            m = YAML_KV_RE.match(line)
            if not m:
                continue
            indent, key = len(m.group(1)), m.group(2)
            while stack and stack[-1][0] >= indent:
                stack.pop()
            full = ".".join([k for _, k in stack] + [key])
            rest = line.split(":", 1)[1].split(" #", 1)[0].strip()
            if rest and rest not in ("|", ">", "|-", ">-"):
                out[full] = rest
            stack.append((indent, key))
    return out


def scan_provided():
    """新工程「已提供」的键 = 它自己的 yaml + 这些 yaml 用 spring.config.import 引进来的文件。

    ⚠️ 第二个来源是后补的,缺了它就是本脚本踩过的坑:
    脚本原本只 glob `application*.yaml`,而它自己的 `--emit` 生成的
    `legacy-params.properties` 是**通过 `spring.config.import` 生效**的 ——
    「自己开出的方子自己不认」,于是那 36 个键全被判成「必需但未提供」,
    退出码恒为 1(假阳性),而应用其实启动得好好的。
    所以这里按 `spring.config.import` 实际声明的文件名去读,而不是把文件名写死:
    import 列表改了,这里跟着改。
    """
    provided = defaultdict(list)
    res_dir = os.path.join(CLOUD, "parking-server/src/main/resources")
    yamls = []
    for pat in ("application.yaml", "application.yml", "application-*.yaml", "application-*.yml"):
        yamls += sorted(glob.glob(os.path.join(res_dir, pat)))

    flats = {}
    for p in yamls:
        flats[p] = flatten_yaml(p)
        for k, v in flats[p].items():
            provided[k].append((os.path.basename(p), v))

    for p in yamls:
        for raw in flats[p].get("spring.config.import", "").split(","):
            name = strip_location(raw)
            full = os.path.join(res_dir, name) if name else ""
            if not name or not os.path.isfile(full):
                continue
            keys = (flatten_yaml(full) if name.endswith((".yaml", ".yml"))
                    else read_properties(full))
            for k, v in keys.items():
                provided[k].append((name, v))
    return provided


def strip_location(loc):
    """`classpath:legacy-params.properties` / `optional:classpath:x.yml` → 去前缀去引号。"""
    loc = loc.strip().strip("'\"")
    while True:
        for pfx in ("optional:", "classpath*:", "classpath:", "file:"):
            if loc.startswith(pfx):
                loc = loc[len(pfx):]
                break
        else:
            return loc


def read_properties(p):
    out = {}
    with open(p, encoding="utf-8", errors="replace") as fh:
        for line in fh:
            s = line.strip()
            if not s or s[0] in "#!":
                continue
            m = PROP_KV_RE.match(line)
            if m:
                out[m.group(1)] = m.group(2).split(" #", 1)[0].strip()
    return out


def scan_legacy():
    """→ {key: {(模块, 文件): 值}}(含所有 profile 文件,用于发现「同键不同值」)"""
    legacy = defaultdict(dict)
    for p in glob.glob(os.path.join(LEGACY, "*", "src/main/resources/application*.properties")):
        rel = os.path.relpath(p, LEGACY)
        module, fname = rel.split(os.sep)[0], os.path.basename(p)
        with open(p, encoding="utf-8", errors="replace") as fh:
            for line in fh:
                s = line.strip()
                if not s or s[0] in "#!":
                    continue
                m = PROP_KV_RE.match(line)
                if m:
                    legacy[m.group(1)][(module, fname)] = m.group(2).strip()
    return legacy


def legacy_baseline(legacy, key):
    """老工程的基线值:优先 `application.properties`(那是默认环境),否则取任一。"""
    d = legacy.get(key)
    if not d:
        return None, []
    for (module, fname), v in d.items():
        if fname == "application.properties":
            return (module, v), sorted({x for x in d.values()})
    # 没有默认环境文件(如 zteits-charge 只有 application-danyang.properties)→ 取第一个
    ((module, fname), v) = sorted(d.items())[0]
    return (module, v), sorted({x for x in d.values()})


# --------------------------------------------------------------------------- #
# 2) 扫描范围(哪些包里的类会成为 Bean)
# --------------------------------------------------------------------------- #
def resolve_placeholder_value(expr, provided):
    """把 `${parking.info.base-package}.server` 用新工程 yaml 里的值代换成 `com.zteits.parking.server`。
    解不出来就原样返回(带 `${}` 的字符串匹配不上任何包名,等于不算在扫描范围内 —— 宁可少算,
    由 --show-scan 打印出来人工核对)。"""
    m = re.fullmatch(r'\$\{([^}]*)\}(.*)', expr)
    if not m:
        return expr
    key, default = split_placeholder(m.group(1))
    suffix = m.group(2)
    for _, v in provided.get(key, []):
        if not v.startswith("${"):
            return v.strip("'\"") + suffix
    if default is not None:
        return default + suffix
    return expr


def java_sources():
    for d in SCAN_DIRS:
        root = os.path.join(CLOUD, d)
        for dirpath, _, files in os.walk(root):
            if os.sep + "target" + os.sep in dirpath + os.sep:
                continue
            for f in files:
                if f.endswith(".java"):
                    yield os.path.join(dirpath, f)


def scan_config():
    """→ (扫描包集合, 被 excludeFilters 排除的 FQCN 集合, 每个文件的包与是否为组件)"""
    provided = scan_provided()
    excluded = set()
    packs = set()

    def read_scan(text):
        """从一段源码里抽出 @ComponentScan 的 basePackages(无参数则返回 None=本类包)"""
        found = []
        for m in SCAN_ANNO_RE.finditer(text):
            end, inner = balanced(text, m.end() - 1)
            bp = re.search(r'basePackages\s*=\s*\{', inner)
            if bp:
                content, _ = brace_span(inner, bp.end() - 1)
                for raw in re.findall(r'"([^"]*)"', content):
                    found.append(resolve_placeholder_value(raw, provided))
            else:
                # 无参数(扫本类所在包)或 basePackageClasses 写法 —— 都交给调用方按「本类包」处理,
                # 后者会在 --show-scan 的结果里显得多算了几个包,人工核对即可
                found.append(None)
            if "excludeFilters" in inner:
                for cl in re.finditer(r'classes\s*=\s*\{', inner):
                    content, _ = brace_span(inner, cl.end() - 1)
                    for fq in re.findall(r'([A-Z][\w.]*\.[A-Z][\w.]*)\.class', content):
                        excluded.add(fq)
        return found

    app_path = os.path.join(CLOUD, APP_JAVA)
    app_text = strip_comments(open(app_path, encoding="utf-8").read())
    app_pkg = PKG_RE.search(app_text).group(1)
    for p in read_scan(app_text):
        packs.add(p or app_pkg)

    # 递归到不动点:扫描范围内的类若自己带 @ComponentScan(老代码里真有,见 AGENTS.md 坑 14)
    changed = True
    while changed:
        changed = False
        for path in java_sources():
            text = strip_comments(open(path, encoding="utf-8", errors="replace").read())
            pm = PKG_RE.search(text)
            if not pm:
                continue
            pkg = pm.group(1)
            if not any(pkg == p or pkg.startswith(p + ".") for p in packs):
                continue
            for target in read_scan(text):
                t = target or pkg
                if t not in packs:
                    packs.add(t)
                    changed = True
    return packs, excluded


def class_is_bean(text, pkg, excluded, packs):
    """类会成为 Bean 吗?(粗判:包在扫描范围内 + 有组件注解 + 未被排除)"""
    if not any(pkg == p or pkg.startswith(p + ".") for p in packs):
        return False, "包不在扫描范围内"
    cls = re.search(r'\b(?:class|interface)\s+(\w+)', text)
    fqn = f"{pkg}.{cls.group(1)}" if cls else None
    if fqn in excluded:
        return False, f"已被 excludeFilters 排除({fqn})"
    head = text[:cls.start()] if cls else text
    if not any(s in head for s in STEREOTYPES):
        return False, "类上没有组件注解"
    return True, ""


# --------------------------------------------------------------------------- #
# 3) 收集 @Value 需求
# --------------------------------------------------------------------------- #
def scan_required(packs, excluded):
    """→ required{key: [entry]}, skipped[entry], with_default[entry]"""
    required, skipped, with_default = defaultdict(list), [], []
    for path in java_sources():
        raw = open(path, encoding="utf-8", errors="replace").read()
        text = strip_comments(raw)
        pm = PKG_RE.search(text)
        if not pm:
            continue
        pkg = pm.group(1)
        is_bean, why = class_is_bean(text, pkg, excluded, packs)
        rel = os.path.relpath(path, CLOUD)
        for m in VALUE_RE.finditer(text):
            key, default = split_placeholder(m.group(1))
            if not key:
                continue
            lineno = text[:m.start()].count("\n") + 1
            # 注解之后到第一个 ';' 的片段里出现 static → Spring 不注入 static 字段
            tail = text[m.end():m.end() + 400]
            decl = tail.split(";", 1)[0]
            on_static = bool(re.search(r'\bstatic\b', decl))
            entry = dict(key=key, default=default, where=f"{rel}:{lineno}", why="")
            if on_static:
                entry["why"] = "注解标在 static 字段上,Spring 不注入"
                skipped.append(entry)
            elif not is_bean:
                entry["why"] = f"所在类不是 Bean:{why}"
                skipped.append(entry)
            elif default is not None:
                with_default.append(entry)
            else:
                required[key].append(entry)
    return required, skipped, with_default


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--all", action="store_true")
    ap.add_argument("--show-scan", action="store_true")
    ap.add_argument("--emit", metavar="PATH", help="生成配置草稿(.properties 格式)")
    args = ap.parse_args()

    packs, excluded = scan_config()
    if args.show_scan:
        print("扫描包(含递归得到的嵌套 @ComponentScan):")
        for p in sorted(packs):
            print(f"  {p}")
        print(f"被 excludeFilters 排除的类:{sorted(excluded) or '(无)'}\n")

    required, skipped, with_default = scan_required(packs, excluded)
    provided = scan_provided()
    legacy = scan_legacy()

    # 值里还可能引用别的占位符(如 ${local.base.url}),递归补进「需要」集合
    needed, queue = {}, list(required)
    referenced = set()
    while queue:
        e = queue.pop()
        key = e["key"] if isinstance(e, dict) else e
        if key in needed:
            continue
        needed[key] = e
        base, _ = legacy_baseline(legacy, key)
        value = base[1] if base else None
        if not value:
            continue
        for ref in NESTED_RE.findall(value):
            rk, _ = split_placeholder(ref)
            if rk and rk not in needed:
                referenced.add(rk)
                queue.append(dict(key=rk, default=None, where=f"被 {key} 的值引用",
                                  why="", referenced_from=key))

    missing = {k: v for k, v in needed.items() if k not in provided}
    conflicts = {}
    for k in needed:
        _, values = legacy_baseline(legacy, k)
        if len(values) > 1:
            conflicts[k] = values

    print("=" * 78)
    print(f"【1】必需但新工程未提供({len(missing)} 个)")
    print("    判据:注解存在(去注释)+ 不在 static 字段上 + 所在类是 Bean + 无默认值")
    print("=" * 78)
    for k in sorted(missing):
        e = missing[k]
        base, values = legacy_baseline(legacy, k)
        tag = "(由别的值引用)" if k in referenced else ""
        print(f"\n  ${k}{tag}")
        if base:
            print(f"      老工程里:{base[0]} → {base[1]!r}")
            if len(values) > 1:
                print(f"      ⚠️ 老工程多个 profile 值不同:{values}")
        else:
            print("      ⚠️ 老工程也没有定义(当初靠服务器上的外部配置/-D 参数注入,需部署时提供)")
        print(f"      引用处:{e['where'] if isinstance(e, dict) else e}")

    print(f"\n{'=' * 78}\n【2】有代码默认值、新工程未配({len(with_default)} 个,需核对老工程值)\n{'=' * 78}")
    for e in sorted(with_default, key=lambda x: x["key"]):
        base, _ = legacy_baseline(legacy, e["key"])
        old = base[1] if base else "(老工程无)"
        flag = "  ⚠️ 与老值不同 → 行为静默改变" if base and base[1] != e["default"] else ""
        print(f"  {e['key']:<48} 代码默认={e['default']!r}  老值={old!r}{flag}")

    if args.all:
        print(f"\n{'=' * 78}\n【3】判为「无需配置」({len(skipped)} 个,全是假阳性)\n{'=' * 78}")
        for e in sorted(skipped, key=lambda x: x["key"]):
            print(f"  {e['key']:<48} {e['why']}")
        print(f"\n{'=' * 78}\n【4】新工程已提供({len(provided)} 个键中,被代码引用的)\n{'=' * 78}")
        for k in sorted(set(needed) & set(provided)):
            print(f"  {k:<48} {provided[k][0][0]} = {provided[k][0][1]}")

    if args.emit:
        lines = [
            "# 存量业务参数(从老工程各模块的 application*.properties 迁回;老工程见 AGENTS.md §3)",
            "# 生成脚本:tools/p2_audit_placeholders.py --emit",
            "# ⚠️ 值里的 ${...} 是**嵌套占位符**,由 Spring 在解析时再展开,不是笔误。",
            "# ⚠️ 凭据类键一律写成 ${环境变量名:},真实值不进仓库(红线第 11 条)。",
            "",
        ]
        secrets = []
        for k in sorted(missing):
            base, values = legacy_baseline(legacy, k)
            env = re.sub(r'[^A-Za-z0-9]+', '_', k).upper()
            if any(h in k.lower() for h in SECRET_HINTS) or (base and _looks_secret(base[1])):
                lines.append(f"{k}=${{{env}:}}")
                secrets.append((k, base[1] if base else None, env))
            elif base:
                lines.append(f"{k}={base[1]}")
            else:
                lines.append(f"#{k}=   # 老工程未定义,需部署时按实际环境提供")
        with open(args.emit, "w", encoding="utf-8") as fh:
            fh.write("\n".join(lines) + "\n")
        print(f"\n已写出草稿:{args.emit}({len(missing)} 个键)")
        if secrets:
            print("\n⚠️ 下列键的**老值被判定为凭据,未写入仓库**,改成了环境变量占位,"
                  "部署时必须注入(同时应把这些值加进「待轮换密钥」清单):")
            for k, v, env in secrets:
                print(f"  {k:<40} ← 老值 {v!r}  环境变量 ${env}")

    print(f"\n合计:需要 {len(needed)} 个键(含被引用);缺失 {len(missing)} 个;"
          f"有默认值待核对 {len(with_default)} 个;判为假阳性 {len(skipped)} 个。")
    return 1 if missing else 0


def _looks_secret(v):
    """凭据的启发式判据:长且含混合字符,且不像 URL / 路径 / 明文开关。"""
    if not v or v.startswith("${") or v.startswith("http") or "/" in v:
        return False
    if len(v) < 24:
        return False
    return bool(re.search(r'[a-z]', v) and re.search(r'[A-Z0-9]', v))


if __name__ == "__main__":
    sys.exit(main())