chore: initial commit of ai site platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 10:31:17 +08:00
commit 4ca82fb58a
203 changed files with 45745 additions and 0 deletions

View File

@@ -0,0 +1,396 @@
package authx
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/zeromicro/go-zero/rest/httpx"
)
type ctxKey string
const (
CtxTenantID ctxKey = "tenant_id"
CtxUserID ctxKey = "user_id"
CtxRole ctxKey = "role"
CtxAgentID ctxKey = "agent_id"
CtxPerms ctxKey = "perms"
CtxOrgUnitID ctxKey = "org_unit_id"
)
// 平台级角色 → 权限映射(角色、权限一律中文命名)
var RolePermissions = map[string][]string{
Role超级管理员: {
Perm管理租户, // 只管公司一级:列表/新建等;不介入公司内模块、成员、同步
},
Role管理员: {
Perm读取模块, Perm写入模块, Perm发布模块,
Perm新增数据, Perm查询数据, Perm更新数据, Perm删除数据, Perm导出数据, Perm导入数据,
Perm查看审计, Perm上传文件, Perm下载文件,
Perm管理智能体, Perm邀请成员, Perm管理组织,
Perm数据同步,
},
Role编辑: {
Perm读取模块, Perm写入模块,
Perm新增数据, Perm查询数据, Perm更新数据, Perm导出数据, Perm导入数据,
Perm上传文件, Perm下载文件,
},
Role只读: {
Perm读取模块, Perm查询数据, Perm导出数据, Perm下载文件, Perm查看审计,
},
Role待加入: {},
}
type Claims struct {
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
Role string `json:"role,omitempty"`
OrgUnitID int64 `json:"org_unit_id,omitempty"`
AgentID int64 `json:"agent_id,omitempty"`
Perms []string `json:"perms,omitempty"`
jwt.RegisteredClaims
}
type JWTConfig struct {
AccessSecret string
AccessExpire int64
}
func (c JWTConfig) Expire() time.Duration {
if c.AccessExpire <= 0 {
return 24 * time.Hour
}
return time.Duration(c.AccessExpire) * time.Second
}
func IssueToken(cfg JWTConfig, tenantID, userID int64, role string, orgUnitID int64) (string, int64, error) {
if cfg.AccessSecret == "" {
return "", 0, errors.New("auth access secret empty")
}
if userID <= 0 {
return "", 0, errors.New("user_id required")
}
if role == "" {
role = Role管理员
}
role = NormalizeRole(role)
if _, ok := RolePermissions[role]; !ok {
return "", 0, fmt.Errorf("unknown role: %s", role)
}
// 待加入 / 超级管理员可无租户;其他角色必须带 tenant_id
if tenantID <= 0 && role != Role待加入 && role != Role超级管理员 {
return "", 0, errors.New("tenant_id and user_id required")
}
if tenantID < 0 {
tenantID = 0
}
return signClaims(cfg, Claims{
TenantID: tenantID,
UserID: userID,
Role: role,
OrgUnitID: orgUnitID,
})
}
// IssueAgentToken 签发智能体 JWTrole=agent权限写在 perms 声明中)。
func IssueAgentToken(cfg JWTConfig, tenantID, agentID int64, perms []string) (string, int64, error) {
if cfg.AccessSecret == "" {
return "", 0, errors.New("auth access secret empty")
}
if tenantID <= 0 || agentID <= 0 {
return "", 0, errors.New("tenant_id and agent_id required")
}
return signClaims(cfg, Claims{
TenantID: tenantID,
UserID: agentID,
Role: RoleAgent,
AgentID: agentID,
Perms: NormalizePerms(perms),
})
}
func signClaims(cfg JWTConfig, claims Claims) (string, int64, error) {
exp := time.Now().Add(cfg.Expire())
claims.RegisteredClaims = jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(exp),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "aijianzhan-platform",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(cfg.AccessSecret))
if err != nil {
return "", 0, err
}
return signed, exp.Unix(), nil
}
func ParseToken(cfg JWTConfig, tokenStr string) (*Claims, error) {
if cfg.AccessSecret == "" {
return nil, errors.New("auth access secret empty")
}
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}))
token, err := parser.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
return []byte(cfg.AccessSecret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
if claims.UserID <= 0 {
return nil, errors.New("token missing user")
}
if claims.Role == "" {
claims.Role = Role只读
}
claims.Role = NormalizeRole(claims.Role)
if claims.TenantID <= 0 && claims.Role != Role待加入 && claims.Role != Role超级管理员 {
return nil, errors.New("token missing tenant/user")
}
if claims.TenantID < 0 {
claims.TenantID = 0
}
if claims.Role == Role智能体 && claims.AgentID <= 0 {
claims.AgentID = claims.UserID
}
return claims, nil
}
func Middleware(jwtCfg JWTConfig, devAuth bool) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
raw := strings.TrimSpace(auth[7:])
claims, err := ParseToken(jwtCfg, raw)
if err != nil {
WriteError(w, http.StatusUnauthorized, "invalid token: "+err.Error())
return
}
ctx := WithFullClaims(r.Context(), claims.TenantID, claims.UserID, claims.Role, claims.AgentID, claims.OrgUnitID, claims.Perms)
next(w, r.WithContext(ctx))
return
}
if devAuth {
tenantID, _ := strconv.ParseInt(r.Header.Get("X-Tenant-Id"), 10, 64)
userID, _ := strconv.ParseInt(r.Header.Get("X-User-Id"), 10, 64)
role := r.Header.Get("X-Role")
if tenantID <= 0 {
tenantID = 1
}
if userID <= 0 {
userID = 1
}
if role == "" {
role = Role管理员
}
role = NormalizeRole(role)
ctx := WithClaims(r.Context(), tenantID, userID, role)
next(w, r.WithContext(ctx))
return
}
WriteError(w, http.StatusUnauthorized, "missing Authorization Bearer token")
}
}
}
// RequirePermission 校验平台角色或智能体 JWT 内嵌权限。
func RequirePermission(perm string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !HasPermissionCtx(r.Context(), perm) {
role := Role(r.Context())
WriteError(w, http.StatusForbidden, "permission denied: "+perm+" (role="+role+")")
return
}
next(w, r)
}
}
}
// RequireTenant 要求已加入租户pending / 无 tenant 不可访问业务数据。
// 超级管理员可在「进入某公司」后携带 tenant_id 访问公司内接口(可看可改,前端会多重确认)。
func RequireTenant() func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if Role(r.Context()) == Role智能体 {
if TenantID(r.Context()) <= 0 {
WriteError(w, http.StatusForbidden, "agent missing tenant")
return
}
next(w, r)
return
}
if Role(r.Context()) == Role超级管理员 {
if TenantID(r.Context()) <= 0 {
WriteError(w, http.StatusForbidden, "请先在平台管理中进入某公司后再操作公司内部功能")
return
}
next(w, r)
return
}
if TenantID(r.Context()) <= 0 || Role(r.Context()) == Role待加入 {
WriteError(w, http.StatusForbidden, "join a tenant first (pending membership)")
return
}
next(w, r)
}
}
}
// RequirePlatformAdmin 仅平台超级管理员。
func RequirePlatformAdmin() func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !IsPlatformAdmin(Role(r.Context())) {
WriteError(w, http.StatusForbidden, "需要超级管理员")
return
}
next(w, r)
}
}
}
// RequireTenantBound 保留兼容:业务接口要求已绑定租户。
func RequireTenantBound() func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if IsPlatformAdmin(Role(r.Context())) {
if TenantID(r.Context()) <= 0 {
WriteError(w, http.StatusForbidden, "请先进入某公司")
return
}
next(w, r)
return
}
if TenantID(r.Context()) <= 0 {
WriteError(w, http.StatusForbidden, "请先加入公司")
return
}
next(w, r)
}
}
}
func HasPermission(role, perm string) bool {
perms, ok := RolePermissions[NormalizeRole(role)]
if !ok {
return false
}
want := NormalizePerm(perm)
for _, p := range perms {
if NormalizePerm(p) == want {
return true
}
}
return false
}
func HasPermissionCtx(ctx context.Context, perm string) bool {
role := NormalizeRole(Role(ctx))
want := NormalizePerm(perm)
if role == Role超级管理员 {
// 进入公司后:超管可使用全部公司权限(平台专属「管理租户」仍保留)
if want == Perm管理租户 {
return true
}
for _, p := range CompanyPermCatalog() {
if NormalizePerm(p) == want {
return TenantID(ctx) > 0
}
}
return HasPermission(role, want)
}
if role == Role智能体 {
ok := false
for _, p := range Perms(ctx) {
if NormalizePerm(p) == want {
ok = true
break
}
}
if !ok {
return false
}
} else if !HasPermission(role, want) {
return false
}
tid := TenantID(ctx)
if tid > 0 && EntitlementChecker != nil {
if !EntitlementChecker(ctx, tid, want) {
return false
}
}
return true
}
func withClaims(ctx context.Context, tenantID, userID int64, role string) context.Context {
return WithClaims(ctx, tenantID, userID, role)
}
// WithClaims 写入租户/用户/角色(公开展示等场景用)。
func WithClaims(ctx context.Context, tenantID, userID int64, role string) context.Context {
return WithFullClaims(ctx, tenantID, userID, role, 0, 0, nil)
}
func WithFullClaims(ctx context.Context, tenantID, userID int64, role string, agentID, orgUnitID int64, perms []string) context.Context {
ctx = context.WithValue(ctx, CtxTenantID, tenantID)
ctx = context.WithValue(ctx, CtxUserID, userID)
ctx = context.WithValue(ctx, CtxRole, role)
ctx = context.WithValue(ctx, CtxAgentID, agentID)
ctx = context.WithValue(ctx, CtxOrgUnitID, orgUnitID)
if perms != nil {
ctx = context.WithValue(ctx, CtxPerms, append([]string{}, perms...))
}
return ctx
}
func TenantID(ctx context.Context) int64 {
v, _ := ctx.Value(CtxTenantID).(int64)
return v
}
func UserID(ctx context.Context) int64 {
v, _ := ctx.Value(CtxUserID).(int64)
return v
}
func AgentID(ctx context.Context) int64 {
v, _ := ctx.Value(CtxAgentID).(int64)
return v
}
func OrgUnitID(ctx context.Context) int64 {
v, _ := ctx.Value(CtxOrgUnitID).(int64)
return v
}
func Role(ctx context.Context) string {
v, _ := ctx.Value(CtxRole).(string)
if v == "" {
return Role只读
}
return NormalizeRole(v)
}
func Perms(ctx context.Context) []string {
v, _ := ctx.Value(CtxPerms).([]string)
return v
}
func WriteError(w http.ResponseWriter, code int, msg string) {
httpx.WriteJson(w, code, map[string]any{
"code": code,
"message": msg,
})
}

View File

@@ -0,0 +1,78 @@
package authx_test
import (
"net/http"
"net/http/httptest"
"testing"
"aijianzhan/platform/internal/authx"
)
func TestIssueAndParseToken(t *testing.T) {
cfg := authx.JWTConfig{AccessSecret: "test-secret", AccessExpire: 3600}
tok, exp, err := authx.IssueToken(cfg, 9, 42, "owner", 0)
if err != nil {
t.Fatal(err)
}
if tok == "" || exp <= 0 {
t.Fatal("empty token")
}
claims, err := authx.ParseToken(cfg, tok)
if err != nil {
t.Fatal(err)
}
if claims.TenantID != 9 || claims.UserID != 42 {
t.Fatalf("claims mismatch: %+v", claims)
}
}
func TestMiddlewareRequiresJWT(t *testing.T) {
cfg := authx.JWTConfig{AccessSecret: "test-secret", AccessExpire: 3600}
mw := authx.Middleware(cfg, false)
called := false
h := mw(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
rr := httptest.NewRecorder()
h(rr, req)
if rr.Code != http.StatusUnauthorized || called {
t.Fatalf("expected 401 without token, got %d called=%v", rr.Code, called)
}
tok, _, err := authx.IssueToken(cfg, 1, 2, "owner", 0)
if err != nil {
t.Fatal(err)
}
req2 := httptest.NewRequest(http.MethodGet, "/x", nil)
req2.Header.Set("Authorization", "Bearer "+tok)
rr2 := httptest.NewRecorder()
h(rr2, req2)
if rr2.Code != http.StatusOK || !called {
t.Fatalf("expected 200 with token, got %d", rr2.Code)
}
if authx.TenantID(req2.Context()) != 0 {
// context is on the request passed to next; check via handler
}
}
func TestMiddlewareInjectsClaims(t *testing.T) {
cfg := authx.JWTConfig{AccessSecret: "test-secret", AccessExpire: 3600}
tok, _, _ := authx.IssueToken(cfg, 7, 8, "editor", 0)
mw := authx.Middleware(cfg, false)
var gotTenant, gotUser int64
h := mw(func(w http.ResponseWriter, r *http.Request) {
gotTenant = authx.TenantID(r.Context())
gotUser = authx.UserID(r.Context())
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
h(rr, req)
if gotTenant != 7 || gotUser != 8 {
t.Fatalf("got tenant=%d user=%d", gotTenant, gotUser)
}
}

View File

@@ -0,0 +1,117 @@
package authx
import (
"context"
"fmt"
"strings"
)
// CompanyPermCatalog 公司可被授予的权限模块(不含平台专属「管理租户」)。
func CompanyPermCatalog() []string {
return []string{
Perm读取模块, Perm写入模块, Perm发布模块,
Perm查询数据, Perm新增数据, Perm更新数据, Perm删除数据, Perm导入数据, Perm导出数据,
Perm下载文件, Perm上传文件,
Perm查看审计,
Perm管理智能体, Perm邀请成员, Perm管理组织,
Perm数据同步,
}
}
// PermModule 权限模块分组。
type PermModule struct {
Title string `json:"title"`
Items []PermItem `json:"items"`
}
type PermItem struct {
Perm string `json:"perm"`
Desc string `json:"desc"`
}
// PermDesc 权限说明(超管授权 / 公司分配时展示)。
func PermDesc(p string) string {
switch NormalizePerm(p) {
case Perm读取模块:
return "查看模块列表、蓝图与智能体胶囊"
case Perm写入模块:
return "保存在建草稿(不发布)"
case Perm发布模块:
return "发布/更新线上模块"
case Perm查询数据:
return "查询业务列表与详情"
case Perm新增数据:
return "新增业务数据行"
case Perm更新数据:
return "更新业务数据行"
case Perm删除数据:
return "删除业务数据行"
case Perm导入数据:
return "导入 Excel/CSV/JSON"
case Perm导出数据:
return "导出业务数据"
case Perm下载文件:
return "下载已上传文件"
case Perm上传文件:
return "上传附件与素材"
case Perm查看审计:
return "查看操作审计日志"
case Perm管理智能体:
return "管理智能体账号与角色权限"
case Perm邀请成员:
return "生成邀请码、管理成员角色"
case Perm管理组织:
return "管理组织单元与成员归属"
case Perm数据同步:
return "配置跨库数据同步通道"
case Perm管理租户:
return "平台级:管理公司与权限额度"
default:
return p
}
}
func PermModules() []PermModule {
mk := func(title string, perms ...string) PermModule {
items := make([]PermItem, 0, len(perms))
for _, p := range perms {
items = append(items, PermItem{Perm: p, Desc: PermDesc(p)})
}
return PermModule{Title: title, Items: items}
}
return []PermModule{
mk("模块", Perm读取模块, Perm写入模块, Perm发布模块),
mk("数据", Perm查询数据, Perm新增数据, Perm更新数据, Perm删除数据, Perm导入数据, Perm导出数据),
mk("文件", Perm下载文件, Perm上传文件),
mk("审计", Perm查看审计),
mk("公司管理", Perm管理智能体, Perm邀请成员, Perm管理组织),
mk("数据同步", Perm数据同步),
}
}
// FilterWithinAllowance 只保留额度内的权限。
func FilterWithinAllowance(want, allowance []string) ([]string, []string) {
allow := map[string]struct{}{}
for _, p := range NormalizePerms(allowance) {
allow[p] = struct{}{}
}
var ok, denied []string
for _, p := range NormalizePerms(want) {
if _, hit := allow[p]; hit {
ok = append(ok, p)
} else {
denied = append(denied, p)
}
}
return ok, denied
}
var EntitlementChecker func(ctx context.Context, tenantID int64, perm string) bool
func AssertWithinEntitlement(want, allowance []string) error {
_, denied := FilterWithinAllowance(want, allowance)
if len(denied) == 0 {
return nil
}
return fmt.Errorf("超出公司权限额度: %s", strings.Join(denied, "、"))
}

View File

@@ -0,0 +1,86 @@
package authx
// 权限统一用中文命名。英文旧码仍可通过 NormalizePerm 兼容。
const (
Perm读取模块 = "读取模块"
Perm写入模块 = "写入模块"
Perm发布模块 = "发布模块"
Perm新增数据 = "新增数据"
Perm查询数据 = "查询数据"
Perm更新数据 = "更新数据"
Perm删除数据 = "删除数据"
Perm导出数据 = "导出数据"
Perm导入数据 = "导入数据"
Perm查看审计 = "查看审计"
Perm上传文件 = "上传文件"
Perm下载文件 = "下载文件"
Perm管理智能体 = "管理智能体"
Perm邀请成员 = "邀请成员"
Perm管理组织 = "管理组织"
Perm数据同步 = "数据同步"
Perm管理租户 = "管理租户" // 平台超级管理员:跨公司
)
// permAlias英文旧码 → 中文规范名;中文自身映射到自身。
var permAlias = map[string]string{
"app.read": Perm读取模块,
"app.write": Perm写入模块,
"app.admin": Perm发布模块,
"row.create": Perm新增数据,
"row.read": Perm查询数据,
"row.update": Perm更新数据,
"row.delete": Perm删除数据,
"row.export": Perm导出数据,
"row.import": Perm导入数据,
"audit.read": Perm查看审计,
"storage.write": Perm上传文件,
"storage.read": Perm下载文件,
"agent.admin": Perm管理智能体,
"tenant.invite": Perm邀请成员,
"org.admin": Perm管理组织,
"sync.admin": Perm数据同步,
"tenant.admin": Perm管理租户,
Perm读取模块: Perm读取模块,
Perm写入模块: Perm写入模块,
Perm发布模块: Perm发布模块,
Perm新增数据: Perm新增数据,
Perm查询数据: Perm查询数据,
Perm更新数据: Perm更新数据,
Perm删除数据: Perm删除数据,
Perm导出数据: Perm导出数据,
Perm导入数据: Perm导入数据,
Perm查看审计: Perm查看审计,
Perm上传文件: Perm上传文件,
Perm下载文件: Perm下载文件,
Perm管理智能体: Perm管理智能体,
Perm邀请成员: Perm邀请成员,
Perm管理组织: Perm管理组织,
Perm数据同步: Perm数据同步,
Perm管理租户: Perm管理租户,
}
// NormalizePerm 将权限统一为中文规范名(未知则原样返回)。
func NormalizePerm(p string) string {
if c, ok := permAlias[p]; ok {
return c
}
return p
}
// NormalizePerms 去重并转为中文规范名。
func NormalizePerms(perms []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(perms))
for _, p := range perms {
c := NormalizePerm(p)
if c == "" {
continue
}
if _, ok := seen[c]; ok {
continue
}
seen[c] = struct{}{}
out = append(out, c)
}
return out
}

View File

@@ -0,0 +1,93 @@
package authx
// 平台账号角色JWT role——统一中文命名。
const (
Role超级管理员 = "超级管理员" // 平台级:可管理所有公司
Role管理员 = "管理员" // 公司顶级
Role编辑 = "编辑"
Role只读 = "只读"
Role待加入 = "待加入"
Role智能体 = "智能体"
)
// RoleAgent 智能体 JWT 角色(与 Role智能体 相同)。
const RoleAgent = Role智能体
// 智能体租户角色编码roles 表 code——统一中文。
const (
AgentRole生成发布 = "生成发布"
AgentRole只读 = "只读"
AgentRole读写 = "读写"
AgentRole运维 = "运维"
)
// NormalizeRole 将平台角色统一为中文(兼容英文旧码)。
func NormalizeRole(role string) string {
switch role {
case "platform_admin", "super_admin", Role超级管理员:
return Role超级管理员
case "owner", Role管理员:
return Role管理员
case "editor", Role编辑:
return Role编辑
case "viewer", Role只读:
return Role只读
case "pending", Role待加入:
return Role待加入
case "agent", Role智能体:
return Role智能体
default:
return role
}
}
// NormalizeAgentRoleCode 智能体角色编码 → 中文(兼容英文旧码)。
func NormalizeAgentRoleCode(code string) string {
switch code {
case "publisher", AgentRole生成发布:
return AgentRole生成发布
case "viewer", AgentRole只读:
return AgentRole只读
case "editor", AgentRole读写:
return AgentRole读写
case "operator", AgentRole运维:
return AgentRole运维
default:
return code
}
}
// IsPlatformAdmin 是否平台超级管理员(可跨公司)。
func IsPlatformAdmin(role string) bool {
return NormalizeRole(role) == Role超级管理员
}
// IsOwner 是否公司顶级管理员(不含超级管理员)。
func IsOwner(role string) bool {
return NormalizeRole(role) == Role管理员
}
// IsCompanyAdmin 公司内顶级管理员(超级管理员不介入公司内部)。
func IsCompanyAdmin(role string) bool {
return NormalizeRole(role) == Role管理员
}
// IsAgent 是否智能体账号。
func IsAgent(role string) bool {
return NormalizeRole(role) == Role智能体
}
// RoleLabel 平台角色 → 中文显示(已是中文则原样)。
func RoleLabel(role string) string {
return NormalizeRole(role)
}
// ValidPlatformRole 是否可赋予人类成员的角色(不含待加入/智能体/超级管理员)。
func ValidPlatformRole(role string) bool {
switch NormalizeRole(role) {
case Role管理员, Role编辑, Role只读:
return true
default:
return false
}
}