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:
SpecialX
2026-07-10 12:58:22 +08:00
parent 2a2a56f541
commit faaaf29f67
120 changed files with 23201 additions and 2 deletions

76
packages/shared-go/env/env.go vendored Normal file
View 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
}

View File

@@ -0,0 +1,2 @@
# buf generate 产物输出到此目录(见 packages/shared-proto/buf.gen.yaml
# 保留此文件以便 gen/proto 目录在产物生成前被 git 跟踪。

View 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
)

View 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
}

View 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
}

View 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)
}