package applogic import ( "context" "fmt" "log" "strings" "aijianzhan/platform/internal/agentcap" "aijianzhan/platform/internal/authx" "aijianzhan/platform/internal/meta" "aijianzhan/platform/internal/smsstore" "aijianzhan/platform/internal/svc" "aijianzhan/platform/internal/types" "aijianzhan/platform/internal/userstore" ) type AuthLogic struct { ctx context.Context svcCtx *svc.ServiceContext } func NewAuthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AuthLogic { return &AuthLogic{ctx: ctx, svcCtx: svcCtx} } func (l *AuthLogic) IssueToken(req *types.TokenReq) (*types.TokenResp, error) { gt := strings.TrimSpace(strings.ToLower(req.GrantType)) if gt == "client_credentials" || (req.ClientID != "" && req.ClientSecret != "") { return l.IssueClientCredentials(req.ClientID, req.ClientSecret) } want := l.svcCtx.Config.Auth.IssueSecret if want == "" { want = l.svcCtx.JWT.AccessSecret } if req.Secret == "" || req.Secret != want { return nil, fmt.Errorf("invalid issue secret") } role := req.Role if role == "" { role = authx.Role管理员 } return l.issue(req.TenantID, req.UserID, role, "", "", 0) } func (l *AuthLogic) Register(req *types.RegisterReq) (*types.TokenResp, error) { if l.phoneLoginOnly() { return nil, fmt.Errorf("本系统仅支持手机号登录,请联系管理员开通账号并绑定手机") } if l.svcCtx.Users == nil { return nil, fmt.Errorf("user store unavailable") } u, err := l.svcCtx.Users.Register(l.ctx, req.Username, req.Password, req.DisplayName) if err != nil { return nil, err } return l.issueUser(u) } func (l *AuthLogic) phoneLoginOnly() bool { // 有部署期限控制时,不必强制手机号登录 if l.svcCtx.Config.License.Enabled { return false } return l.svcCtx.Config.Auth.PhoneLoginOnly } // PhoneLoginOnlyPolicy 对外暴露:是否强制仅手机号登录(License 开启时为 false)。 func (l *AuthLogic) PhoneLoginOnlyPolicy() bool { return l.phoneLoginOnly() } func (l *AuthLogic) Login(req *types.LoginReq) (*types.TokenResp, error) { if l.svcCtx.Users == nil { return nil, fmt.Errorf("user store unavailable") } smsCode := strings.TrimSpace(req.SMSCode) phone := strings.TrimSpace(req.Phone) account := strings.TrimSpace(req.Username) if account == "" { account = phone } // 手机号 + 短信验证码 if smsCode != "" { if phone == "" { phone = account } ns, err := userstore.NormalizePhone(phone) if err != nil { return nil, err } if l.svcCtx.SMS == nil { return nil, fmt.Errorf("短信服务未启用") } if err := l.svcCtx.SMS.Consume(smsstore.PurposeLogin, ns, smsCode); err != nil { return nil, err } u, err := l.svcCtx.Users.GetByPhone(l.ctx, ns) if err != nil { return nil, fmt.Errorf("该手机号未绑定账号") } if u.Status == "disabled" { return nil, fmt.Errorf("账号已停用") } return l.issueUser(u) } if strings.TrimSpace(req.Password) == "" { return nil, fmt.Errorf("请填写密码或短信验证码") } // 仅手机号登录:必须用手机号+密码 if l.phoneLoginOnly() { loginPhone := phone if loginPhone == "" { loginPhone = account } ns, err := userstore.NormalizePhone(loginPhone) if err != nil { return nil, fmt.Errorf("请使用手机号登录") } u, err := l.svcCtx.Users.Login(l.ctx, ns, req.Password) if err != nil { return nil, err } if err := l.enforceLoginPolicy(u, false); err != nil { return nil, err } return l.issueUser(u) } // 兼容模式:用户名/手机号 + 密码 if account == "" { return nil, fmt.Errorf("请填写用户名或手机号") } u, err := l.svcCtx.Users.Login(l.ctx, account, req.Password) if err != nil { return nil, err } viaUsername := !userstore.LooksLikePhone(account) if err := l.enforceLoginPolicy(u, viaUsername); err != nil { return nil, err } return l.issueUser(u) } func (l *AuthLogic) enforceLoginPolicy(u *userstore.User, viaUsername bool) error { cfg := l.svcCtx.Config.Auth phoneOnly := l.phoneLoginOnly() requirePhone := cfg.RequirePhoneBound || phoneOnly if requirePhone && strings.TrimSpace(u.Phone) == "" { return fmt.Errorf("本部署要求绑定手机号后才能登录,请联系管理员") } if !viaUsername { return nil } if phoneOnly || cfg.DisableUsernameLoginIfPhoneBound || u.UsernameLoginDisabled { if strings.TrimSpace(u.Phone) == "" { return fmt.Errorf("已禁用用户名登录,请联系管理员绑定手机号") } return fmt.Errorf("已禁用用户名登录,请使用手机号+密码或短信验证码") } return nil } type SendSMSResult struct { ExpiresIn int RetryAfter int DebugCode string Message string } func (l *AuthLogic) SendLoginSMS(phone string) (*SendSMSResult, error) { if l.svcCtx.Users == nil || l.svcCtx.SMS == nil { return nil, fmt.Errorf("短信服务未启用") } provider := strings.ToLower(strings.TrimSpace(l.svcCtx.Config.SMS.Provider)) if provider == "off" || provider == "disabled" { return nil, fmt.Errorf("短信登录未开启") } ns, err := userstore.NormalizePhone(phone) if err != nil { return nil, err } if _, err := l.svcCtx.Users.GetByPhone(l.ctx, ns); err != nil { return nil, fmt.Errorf("该手机号未绑定账号,请联系管理员绑定手机后再登录") } code, expiresIn, retryAfter, err := l.svcCtx.SMS.Issue(smsstore.PurposeLogin, ns) if err != nil { return &SendSMSResult{RetryAfter: retryAfter}, err } out := &SendSMSResult{ ExpiresIn: expiresIn, Message: "验证码已发送", } if provider == "" || provider == "dev" { log.Printf("[sms:dev] login code for %s = %s (expires %ds)", ns, code, expiresIn) out.DebugCode = code out.Message = "开发模式:验证码已写入服务日志(并返回 debug_code)" } return out, nil } func (l *AuthLogic) ChangePassword(oldPassword, newPassword string) error { if l.svcCtx.Users == nil { return fmt.Errorf("user store unavailable") } uid := authx.UserID(l.ctx) if uid <= 0 { return fmt.Errorf("未登录") } return l.svcCtx.Users.ChangePassword(l.ctx, uid, oldPassword, newPassword) } func (l *AuthLogic) BindPhone(phone string) (*userstore.User, error) { if l.svcCtx.Users == nil { return nil, fmt.Errorf("user store unavailable") } uid := authx.UserID(l.ctx) if uid <= 0 { return nil, fmt.Errorf("未登录") } if l.phoneLoginOnly() && strings.TrimSpace(phone) == "" { return nil, fmt.Errorf("本部署仅支持手机号登录,不可解绑手机号") } return l.svcCtx.Users.BindPhone(l.ctx, uid, phone) } func (l *AuthLogic) SetUsernameLoginDisabled(disabled bool) (*userstore.User, error) { if l.svcCtx.Users == nil { return nil, fmt.Errorf("user store unavailable") } uid := authx.UserID(l.ctx) if uid <= 0 { return nil, fmt.Errorf("未登录") } return l.svcCtx.Users.SetUsernameLoginDisabled(l.ctx, uid, disabled) } func (l *AuthLogic) Me() (*userstore.User, error) { if l.svcCtx.Users == nil { return nil, fmt.Errorf("user store unavailable") } uid := authx.UserID(l.ctx) if uid <= 0 { return nil, fmt.Errorf("未登录") } return l.svcCtx.Users.GetByID(l.ctx, uid) } func (l *AuthLogic) issue(tenantID, userID int64, role, username, displayName string, orgUnitID int64) (*types.TokenResp, error) { role = authx.NormalizeRole(role) token, exp, err := authx.IssueToken(l.svcCtx.JWT, tenantID, userID, role, orgUnitID) if err != nil { return nil, err } secret := l.svcCtx.Config.Agent.CapsuleSecret if secret == "" { secret = l.svcCtx.JWT.AccessSecret } return &types.TokenResp{ AccessToken: token, TokenType: "Bearer", ExpiresAt: exp, TenantID: tenantID, UserID: userID, Username: username, DisplayName: displayName, Role: role, OrgUnitID: orgUnitID, AgentKey: agentcap.PublicAgentKey(secret, tenantID, userID), }, nil } type CapsuleLogic struct { ctx context.Context svcCtx *svc.ServiceContext } func NewCapsuleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CapsuleLogic { return &CapsuleLogic{ctx: ctx, svcCtx: svcCtx} } func (l *CapsuleLogic) Build(slug string) (*types.CapsuleResp, error) { tenantID := authx.TenantID(l.ctx) userID := authx.UserID(l.ctx) if authx.Role(l.ctx) == authx.RoleAgent { if l.svcCtx.Agents == nil { return nil, fmt.Errorf("agent store unavailable") } ok, err := l.svcCtx.Agents.HasAppAccess(l.ctx, authx.AgentID(l.ctx), slug) if err != nil { return nil, err } if !ok { return nil, fmt.Errorf("app not granted to agent") } } app, err := l.svcCtx.Meta.GetBySlug(l.ctx, tenantID, slug) if err != nil { return nil, err } if app.Status != meta.StatusPublished { return nil, fmt.Errorf("app not published") } base := l.svcCtx.Config.PublicBaseURL if base == "" { base = fmt.Sprintf("http://127.0.0.1:%d", l.svcCtx.Config.Port) } desc := &agentcap.Descriptor{ Version: "1", BaseURL: strings.TrimRight(base, "/"), AppSlug: slug, TenantHint: fmt.Sprintf("t%d", tenantID), Auth: agentcap.AuthSpec{Type: "bearer_jwt", Header: "Authorization"}, Notes: "Decrypt with agent_key from /auth/token. Never expose plaintext API map in UI.", } for _, r := range app.Blueprint.Apis.Resources { path := r.Path if !strings.HasPrefix(path, "/") { path = "/" + path } full := fmt.Sprintf("/api/v1/apps/%s%s", slug, path) methods := make([]string, 0, len(r.Operations)) for _, op := range r.Operations { switch op { case "list", "get", "export": methods = append(methods, "GET") case "create", "import": methods = append(methods, "POST") case "update": methods = append(methods, "PUT") case "delete": methods = append(methods, "DELETE") } } var entityFields []agentcap.FieldSpec pk := "id" for _, e := range app.Blueprint.Entities { if e.Name != r.Entity { continue } pk = e.PrimaryKey for _, f := range e.Fields { entityFields = append(entityFields, agentcap.FieldSpec{Name: f.Name, Type: f.Type}) } } filters, sorts := []string{}, []string{} if r.List != nil { filters = r.List.AllowedFilters sorts = r.List.AllowedSorts } name := strings.TrimPrefix(path, "/") desc.Resources = append(desc.Resources, agentcap.ResourceSpec{ Name: name, Path: full, Methods: uniqStrings(methods), Filters: filters, Sorts: sorts, Fields: entityFields, PrimaryKey: pk, }) } secret := l.svcCtx.Config.Agent.CapsuleSecret if secret == "" { secret = l.svcCtx.JWT.AccessSecret } key := agentcap.DeriveKey(secret, tenantID, userID) capsule, err := agentcap.Encrypt(key, desc) if err != nil { return nil, err } return &types.CapsuleResp{ Capsule: capsule, Format: agentcap.Prefix, Hint: "仅智能体使用 agent_key 解密;前端只展示密文", }, nil } func uniqStrings(in []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(in)) for _, s := range in { if _, ok := seen[s]; ok { continue } seen[s] = struct{}{} out = append(out, s) } return out }