docs: ai 协作文档体系重构与多 ai 仲裁结果落地
1.AI 协作文档体系重构(objections/worklines/contracts+matrix.md) 2.coord 仲裁文档(final-decisions/cross-review/final-rulings/orchestration) 3.各服务 01/02 文档补全 4.共享包初始化(shared-ts/shared-go/hooks/ui-components/ui-tokens) 5.Proto 契约补全 6.004 架构影响地图更新 7.端口分配表 8.设计规格文档
This commit is contained in:
76
packages/shared-go/env/env.go
vendored
Normal file
76
packages/shared-go/env/env.go
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
// Package env provides typed helpers for reading environment variables.
|
||||
//
|
||||
// All functions treat an unset variable and an empty string as distinct only
|
||||
// when explicitly documented: Must/Get distinguish "not set" from "set to empty"
|
||||
// via os.LookupEnv, while GetInt/GetBool/GetDuration fall back to the default
|
||||
// value when parsing fails.
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Must returns the value of the environment variable named by key.
|
||||
// It panics if the variable is not set, which is intended for values that the
|
||||
// service cannot start without (DB URLs, JWT secrets, ...).
|
||||
func Must(key string) string {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("env: required environment variable %q is not set", key))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Get returns the value of the environment variable named by key, or
|
||||
// defaultValue when the variable is not set.
|
||||
func Get(key, defaultValue string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// GetInt returns the integer value of the environment variable named by key,
|
||||
// or defaultValue when the variable is not set or cannot be parsed as an int.
|
||||
func GetInt(key string, defaultValue int) int {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return n
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// GetBool returns the boolean value of the environment variable named by key,
|
||||
// or defaultValue when the variable is not set or cannot be parsed as a bool
|
||||
// (accepted values are those understood by strconv.ParseBool: 1, t, T, TRUE,
|
||||
// true, True, 0, f, F, FALSE, false, False).
|
||||
func GetBool(key string, defaultValue bool) bool {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return b
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// GetDuration returns the duration value of the environment variable named by
|
||||
// key, or defaultValue when the variable is not set or cannot be parsed by
|
||||
// time.ParseDuration (e.g. "30s", "5m", "2h").
|
||||
func GetDuration(key string, defaultValue time.Duration) time.Duration {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return d
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
2
packages/shared-go/gen/proto/.gitkeep
Normal file
2
packages/shared-go/gen/proto/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# buf generate 产物输出到此目录(见 packages/shared-proto/buf.gen.yaml)。
|
||||
# 保留此文件以便 gen/proto 目录在产物生成前被 git 跟踪。
|
||||
8
packages/shared-go/go.mod
Normal file
8
packages/shared-go/go.mod
Normal file
@@ -0,0 +1,8 @@
|
||||
module github.com/edu-cloud/shared-go
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
go.uber.org/zap v1.27.0
|
||||
github.com/go-chi/chi/v5 v5.0.12
|
||||
)
|
||||
183
packages/shared-go/jwks/jwks.go
Normal file
183
packages/shared-go/jwks/jwks.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Package jwks fetches JSON Web Key Sets and validates RS256 JWTs against them.
|
||||
//
|
||||
// A Fetcher caches parsed RSA public keys in memory for 5 minutes per kid so
|
||||
// repeated token validation does not re-hit the issuer's JWKS endpoint. It is
|
||||
// safe for concurrent use.
|
||||
package jwks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// cacheTTL is how long a cached public key is considered fresh before the
|
||||
// Fetcher re-fetches the JWKS document.
|
||||
const cacheTTL = 5 * time.Minute
|
||||
|
||||
// Claims is the set of assertions carried by an Edu access token. The
|
||||
// embedded RegisteredClaims provides exp, iss, aud and the other standard
|
||||
// JWT claims.
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
DataScope string `json:"data_scope"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// cachedKey holds a parsed public key together with the moment it expires.
|
||||
type cachedKey struct {
|
||||
key *rsa.PublicKey
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// Fetcher downloads and caches a JWKS document and validates tokens against
|
||||
// the cached keys.
|
||||
type Fetcher struct {
|
||||
jwksURL string
|
||||
httpClient *http.Client
|
||||
cache sync.Map
|
||||
}
|
||||
|
||||
// NewFetcher returns a Fetcher that retrieves keys from jwksURL.
|
||||
func NewFetcher(jwksURL string) *Fetcher {
|
||||
return &Fetcher{
|
||||
jwksURL: jwksURL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// GetPublicKey returns the RSA public key identified by kid. The key is served
|
||||
// from the in-memory cache when fresh; otherwise the JWKS document is
|
||||
// re-fetched and the cache repopulated.
|
||||
func (f *Fetcher) GetPublicKey(kid string) (*rsa.PublicKey, error) {
|
||||
if v, ok := f.cache.Load(kid); ok {
|
||||
if ck, ok := v.(*cachedKey); ok && time.Now().Before(ck.expiresAt) {
|
||||
return ck.key, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := f.refreshCache(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v, ok := f.cache.Load(kid)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("jwks: kid %q not found in JWKS", kid)
|
||||
}
|
||||
ck, ok := v.(*cachedKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("jwks: cached entry for kid %q has unexpected type", kid)
|
||||
}
|
||||
return ck.key, nil
|
||||
}
|
||||
|
||||
// refreshCache downloads the JWKS document and replaces every cached key.
|
||||
func (f *Fetcher) refreshCache() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.jwksURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jwks: building request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := f.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jwks: fetching JWKS: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("jwks: unexpected status %d from %s", resp.StatusCode, f.jwksURL)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Keys []struct {
|
||||
Kid string `json:"kid"`
|
||||
Kty string `json:"kty"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return fmt.Errorf("jwks: decoding response: %w", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(cacheTTL)
|
||||
for _, k := range body.Keys {
|
||||
if k.Kty != "RSA" {
|
||||
continue
|
||||
}
|
||||
pk, err := buildRSAPublicKey(k.N, k.E)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jwks: parsing key %q: %w", k.Kid, err)
|
||||
}
|
||||
f.cache.Store(k.Kid, &cachedKey{key: pk, expiresAt: expiresAt})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRSAPublicKey reconstructs an rsa.PublicKey from the base64url-encoded
|
||||
// modulus (n) and exponent (e) fields of a JWK.
|
||||
func buildRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
|
||||
nBytes, err := base64.RawURLEncoding.DecodeString(nStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding modulus: %w", err)
|
||||
}
|
||||
eBytes, err := base64.RawURLEncoding.DecodeString(eStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding exponent: %w", err)
|
||||
}
|
||||
|
||||
var e int
|
||||
for _, b := range eBytes {
|
||||
e = e<<8 + int(b)
|
||||
}
|
||||
if e == 0 {
|
||||
return nil, fmt.Errorf("empty exponent")
|
||||
}
|
||||
|
||||
return &rsa.PublicKey{
|
||||
N: new(big.Int).SetBytes(nBytes),
|
||||
E: e,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateToken parses and validates an RS256 JWT using the cached JWKS keys.
|
||||
// The kid in the token header selects the verification key. On success the
|
||||
// populated Claims (including user_id, role, data_scope and the standard
|
||||
// registered claims) are returned.
|
||||
func (f *Fetcher) ValidateToken(token string) (*Claims, error) {
|
||||
parsed, err := jwt.ParseWithClaims(token, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("jwks: unexpected signing method %v", t.Header["alg"])
|
||||
}
|
||||
kid, ok := t.Header["kid"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("jwks: token header missing kid")
|
||||
}
|
||||
pk, err := f.GetPublicKey(kid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pk, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jwks: parsing token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := parsed.Claims.(*Claims)
|
||||
if !ok || !parsed.Valid {
|
||||
return nil, fmt.Errorf("jwks: invalid token claims")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
65
packages/shared-go/logger/logger.go
Normal file
65
packages/shared-go/logger/logger.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Package logger provides a structured logger built on zap with OpenTelemetry
|
||||
// trace correlation.
|
||||
//
|
||||
// In production (the default) logs are emitted as JSON at Info level. When the
|
||||
// ENV environment variable is set to "development" the logger switches to a
|
||||
// console encoder at Debug level for human-readable output.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// ctxKey is an unexported type so context values cannot collide with callers.
|
||||
type ctxKey struct{}
|
||||
|
||||
// fallback is returned by FromContext when no logger has been injected into
|
||||
// the context; it discards all output so callers never receive a nil logger.
|
||||
var fallback = zap.NewNop()
|
||||
|
||||
// New creates a structured logger tagged with serviceName. The encoder and
|
||||
// level are selected from the ENV environment variable as described in the
|
||||
// package documentation.
|
||||
func New(serviceName string) *zap.Logger {
|
||||
var cfg zap.Config
|
||||
if os.Getenv("ENV") == "development" {
|
||||
cfg = zap.NewDevelopmentConfig()
|
||||
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
} else {
|
||||
cfg = zap.NewProductionConfig()
|
||||
cfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
|
||||
}
|
||||
|
||||
l, err := cfg.Build(zap.Fields(zap.String("service", serviceName)))
|
||||
if err != nil {
|
||||
// A logger that cannot be constructed is a fatal misconfiguration;
|
||||
// there is no safe way to continue.
|
||||
panic(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// WithContext returns a copy of ctx that carries l so it can later be
|
||||
// retrieved via FromContext.
|
||||
func WithContext(ctx context.Context, l *zap.Logger) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, l)
|
||||
}
|
||||
|
||||
// FromContext returns the logger stored in ctx. When an active OpenTelemetry
|
||||
// span is present, the returned logger is decorated with a trace_id field so
|
||||
// log lines can be correlated to traces. If no logger was stored in ctx a
|
||||
// no-op logger is returned.
|
||||
func FromContext(ctx context.Context) *zap.Logger {
|
||||
if v, ok := ctx.Value(ctxKey{}).(*zap.Logger); ok && v != nil {
|
||||
if sc := trace.SpanContextFromContext(ctx); sc.HasTraceID() {
|
||||
return v.With(zap.String("trace_id", sc.TraceID().String()))
|
||||
}
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
92
packages/shared-go/tracer/tracer.go
Normal file
92
packages/shared-go/tracer/tracer.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Package tracer bootstraps the OpenTelemetry SDK for a service and exports
|
||||
// traces over OTLP/HTTP (default endpoint localhost:4318, overridable via the
|
||||
// OTEL_EXPORTER_OTLP_ENDPOINT environment variable).
|
||||
//
|
||||
// Typical usage:
|
||||
//
|
||||
// if err := tracer.Init("api-gateway"); err != nil { log.Fatal(err) }
|
||||
// defer tracer.Shutdown(context.Background())
|
||||
package tracer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// tp is the process-wide TracerProvider created by Init and torn down by
|
||||
// Shutdown.
|
||||
var tp *sdktrace.TracerProvider
|
||||
|
||||
// Init initializes the OpenTelemetry SDK with an OTLP HTTP exporter and
|
||||
// registers it as the global TracerProvider. The service is identified by
|
||||
// serviceName on all exported spans.
|
||||
func Init(serviceName string) error {
|
||||
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
if endpoint == "" {
|
||||
endpoint = "localhost:4318"
|
||||
}
|
||||
|
||||
exp, err := otlptracehttp.New(
|
||||
context.Background(),
|
||||
otlptracehttp.WithEndpoint(endpoint),
|
||||
otlptracehttp.WithInsecure(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tracer: creating OTLP exporter: %w", err)
|
||||
}
|
||||
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(serviceName),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tracer: building resource: %w", err)
|
||||
}
|
||||
|
||||
tp = sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(exp),
|
||||
sdktrace.WithResource(res),
|
||||
)
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown flushes pending spans and releases resources held by the
|
||||
// TracerProvider. It is a no-op when Init was never called. The provided
|
||||
// context bounds the flush to 5 seconds.
|
||||
func Shutdown(ctx context.Context) error {
|
||||
if tp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := tp.Shutdown(ctx); err != nil {
|
||||
return fmt.Errorf("tracer: shutting down provider: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FromContext returns the active span from ctx. When no span is active the
|
||||
// returned span is a no-op, so callers may safely record attributes or events
|
||||
// without nil checks.
|
||||
func FromContext(ctx context.Context) trace.Span {
|
||||
return trace.SpanFromContext(ctx)
|
||||
}
|
||||
Reference in New Issue
Block a user