Commit 060c36b6 by luoqi

feat(ai): add platform administration and managed hosting seats

parent 681f3302
Pipeline #3726 failed in 0 seconds
......@@ -6,9 +6,6 @@ AI_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:55432/friday_ai
# 本地 Ed25519 私钥 PEM 或绝对路径,重启沿用;容器部署由 Compose 注入文件路径。
AI_JWT_PRIVATE_KEY=
# FridayAI 自己的 HTTPS 域名,用于出站附件取件;本地可留空。
AI_PUBLIC_BASE_URL=
# PAC 后端读取,不是 FridayAI 的网页地址。
AI_PAC_BASE_URL=http://127.0.0.1:3101
AI_PAC_READ_SECRET=
......
......@@ -37,7 +37,9 @@
"guard:disagreements": "ts-node --transpile-only src/cli/guard-disagreements.cli.ts",
"media:to-oss": "ts-node --transpile-only src/cli/media-to-oss.cli.ts",
"backfill:text": "ts-node --transpile-only src/cli/backfill-text.cli.ts",
"ai:integration": "ts-node --transpile-only src/cli/integration.cli.ts"
"ai:integration": "ts-node --transpile-only src/cli/integration.cli.ts",
"ai:hosting": "ts-node --transpile-only src/cli/hosting-seat.cli.ts",
"ai:platform": "ts-node --transpile-only src/cli/platform.cli.ts"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^2.0.48",
......
CREATE TABLE app.hosting_seats (
id uuid PRIMARY KEY DEFAULT app.uuidv7(),
tenant_id uuid NOT NULL REFERENCES app.tenants(id) ON DELETE RESTRICT,
account_id uuid UNIQUE,
expires_at timestamptz(3), revoked_at timestamptz(3),
authorized_user_id uuid,
bind_expires_at timestamptz(3), reservation_id text UNIQUE, device_id text,
reserved_at timestamptz(3), note text,
created_at timestamptz(3) NOT NULL DEFAULT now(),
updated_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT fk_hosting_account FOREIGN KEY (tenant_id, account_id) REFERENCES app.wecom_accounts(tenant_id, id) ON DELETE RESTRICT,
CONSTRAINT fk_hosting_user FOREIGN KEY (tenant_id, authorized_user_id) REFERENCES app.users(tenant_id, id) ON DELETE RESTRICT,
CONSTRAINT ck_hosting_grant CHECK ((authorized_user_id IS NULL) = (bind_expires_at IS NULL)),
CONSTRAINT ck_hosting_reservation CHECK ((reservation_id IS NULL) = (reserved_at IS NULL)),
CONSTRAINT ck_hosting_device CHECK (device_id IS NULL OR reservation_id IS NOT NULL)
);
CREATE INDEX hosting_seats_tenant_id_authorized_user_id_idx ON app.hosting_seats(tenant_id, authorized_user_id);
CREATE UNIQUE INDEX uq_hosting_pending_user ON app.hosting_seats(tenant_id, authorized_user_id)
WHERE account_id IS NULL AND authorized_user_id IS NOT NULL;
-- 兼容存量:不因升级中断已开通账号。后续可通过 CLI 设置明确的到期时间。
INSERT INTO app.hosting_seats(tenant_id, account_id, note)
SELECT tenant_id, id, '存量账号迁移:永久席位,待运营核对期限' FROM app.wecom_accounts;
BEGIN;
-- 统一账号,分范围授权;客户业务表仍保留 NOT NULL tenant_id 和复合外键。
ALTER TABLE app.users ALTER COLUMN tenant_id DROP NOT NULL;
ALTER TABLE app.users ADD CONSTRAINT ck_platform_user_profile CHECK
(tenant_id IS NOT NULL OR (org_unit_id IS NULL AND job_title IS NULL AND data_scope='scoped'));
CREATE TABLE app.user_role_grants (
id uuid PRIMARY KEY DEFAULT app.uuidv7(),
user_id uuid NOT NULL REFERENCES app.users(id) ON DELETE CASCADE ON UPDATE CASCADE,
scope text NOT NULL, tenant_id uuid REFERENCES app.tenants(id) ON DELETE RESTRICT,
role text NOT NULL, source text NOT NULL DEFAULT 'manual', granted_by uuid,
created_at timestamptz(3) NOT NULL DEFAULT now(), updated_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT uq_role_grant_user_scope UNIQUE(user_id,scope),
CONSTRAINT ck_role_grant_scope CHECK (
(scope='tenant' AND tenant_id IS NOT NULL AND role IN ('owner','staff','viewer')) OR
(scope='platform' AND tenant_id IS NULL AND role IN ('platform_admin','platform_viewer'))),
CONSTRAINT ck_role_grant_source CHECK (source IN ('manual','host','migration')),
CONSTRAINT ck_role_grant_host CHECK (source<>'host' OR scope='tenant'),
CONSTRAINT fk_role_grant_tenant_user FOREIGN KEY(tenant_id,user_id) REFERENCES app.users(tenant_id,id) ON DELETE CASCADE
);
CREATE INDEX user_role_grants_tenant_id_role_idx ON app.user_role_grants(tenant_id,role);
INSERT INTO app.user_role_grants(user_id,scope,tenant_id,role,source)
SELECT id,'tenant',tenant_id,role,CASE WHEN integration_managed_role THEN 'host' ELSE 'migration' END FROM app.users WHERE tenant_id IS NOT NULL;
-- role 是兼容投影,授权表是唯一可修改的角色来源。开户时允许一次初始角色。
CREATE FUNCTION app.project_user_role() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF COALESCE(NEW.scope,OLD.scope)='tenant' THEN
UPDATE app.users SET role=CASE WHEN TG_OP='DELETE' THEN 'viewer' ELSE NEW.role END,
integration_managed_role=CASE WHEN TG_OP='DELETE' THEN false ELSE NEW.source='host' END
WHERE id=COALESCE(NEW.user_id,OLD.user_id);
END IF;
RETURN COALESCE(NEW,OLD);
END $$;
CREATE TRIGGER role_grant_projection AFTER INSERT OR UPDATE OR DELETE ON app.user_role_grants FOR EACH ROW EXECUTE FUNCTION app.project_user_role();
CREATE FUNCTION app.protect_user_role() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id THEN RAISE EXCEPTION '用户租户归属不可直接变更'; END IF;
IF pg_trigger_depth()=1 AND (NEW.role IS DISTINCT FROM OLD.role OR NEW.integration_managed_role IS DISTINCT FROM OLD.integration_managed_role) THEN
RAISE EXCEPTION '请通过 user_role_grants 修改角色';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER user_role_projection_guard BEFORE UPDATE ON app.users FOR EACH ROW EXECUTE FUNCTION app.protect_user_role();
CREATE FUNCTION app.initial_user_role() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.tenant_id IS NOT NULL THEN
INSERT INTO app.user_role_grants(user_id,scope,tenant_id,role,source)
VALUES(NEW.id,'tenant',NEW.tenant_id,NEW.role,CASE WHEN NEW.integration_managed_role THEN 'host' ELSE 'manual' END);
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER user_initial_role AFTER INSERT ON app.users FOR EACH ROW EXECUTE FUNCTION app.initial_user_role();
ALTER TABLE app.auth_sessions ALTER COLUMN tenant_id DROP NOT NULL;
ALTER TABLE app.auth_sessions ADD COLUMN scope text NOT NULL DEFAULT 'tenant';
ALTER TABLE app.auth_sessions ADD CONSTRAINT ck_session_scope CHECK
((scope='tenant' AND tenant_id IS NOT NULL) OR (scope='platform' AND tenant_id IS NULL AND source='password'));
-- 保留原租户复合外键,另补平台账号的单列引用。
ALTER TABLE app.auth_sessions ADD CONSTRAINT fk_session_global_user FOREIGN KEY(user_id) REFERENCES app.users(id) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE app.audit_logs ALTER COLUMN tenant_id DROP NOT NULL;
ALTER TABLE app.audit_logs ADD COLUMN scope text NOT NULL DEFAULT 'tenant';
ALTER TABLE app.audit_logs ADD CONSTRAINT ck_audit_scope CHECK
((scope='tenant' AND tenant_id IS NOT NULL) OR scope='platform');
CREATE INDEX idx_audit_scope_time ON app.audit_logs(scope,occurred_at DESC,id DESC);
COMMIT;
......@@ -306,14 +306,14 @@ model OrgUnitRef {
/// users 就是主体本身(带 tenant_id);**没有全局 principals 层**,理由见 12 §2.1
model User {
id String @id @default(dbgenerated("app.uuidv7()")) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
tenantId String? @map("tenant_id") @db.Uuid
displayName String @map("display_name")
integrationManagedRole Boolean @default(false) @map("integration_managed_role")
integrationManagedName Boolean @default(false) @map("integration_managed_name")
integrationManagedScope Boolean @default(false) @map("integration_managed_scope")
/// role = 能做什么(岗位矩阵)。MIGRATION ck_users_role: owner | staff | viewer
/// ⚠️ 取值集合待产品确认(建议补 'manager'),改它只需改 CHECK + 代码矩阵
/// 客户角色兼容投影,由 user_role_grants 触发器维护;授权从角色授权表读取。
/// MIGRATION ck_users_role: owner | staff | viewer。纯平台用户不使用此字段授权。
role String @default("staff")
/// data_scope = 能看多远。MIGRATION ck_users_scope: tenant | scoped
/// `tenant` = 全租户(特权逃生口);`scoped` = 一律查 user_org_scopes。默认最小权限。
......@@ -366,8 +366,9 @@ model User {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Restrict)
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Restrict)
orgUnit OrgUnit? @relation(fields: [tenantId, orgUnitId], references: [tenantId, id], map: "fk_users_org")
roleGrants UserRoleGrant[]
identities UserIdentity[]
activations UserActivation[]
authSessions AuthSession[]
......@@ -473,9 +474,10 @@ model UserOrgScope {
/// 且审计是 append-only 事实,不该被主数据的删除限制反向约束
model AuditLog {
id String @default(dbgenerated("app.uuidv7()")) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
tenantId String? @map("tenant_id") @db.Uuid
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz(3)
scope String @default("tenant")
action String
objectType String @map("object_type")
objectId String @map("object_id")
......@@ -1877,8 +1879,9 @@ model ConversationHandoff {
/// 网页统一会话:访问 JWT 与刷新凭据均绑定 sid;服务端只保存刷新摘要。
model AuthSession {
id String @id @default(dbgenerated("app.uuidv7()")) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
tenantId String? @map("tenant_id") @db.Uuid
userId String @map("user_id") @db.Uuid
scope String @default("tenant")
source String @default("password")
integrationContext Json? @map("integration_context")
tokenVersion Int @map("token_version")
......@@ -1891,7 +1894,7 @@ model AuthSession {
idleExpiresAt DateTime @map("idle_expires_at") @db.Timestamptz(3)
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [tenantId, userId], references: [tenantId, id], onDelete: Restrict)
user User @relation(fields: [userId], references: [id], onDelete: Restrict, map: "fk_session_global_user")
@@index([tenantId, userId], map: "idx_auth_sessions_user")
@@index([expiresAt], map: "idx_auth_sessions_expiry")
@@map("auth_sessions")
......@@ -1951,3 +1954,42 @@ model IngressReceipt {
@@map("ingress_receipts")
@@schema("app")
}
/// 一个企微号一个托管席位。到期/解除托管均保留占用,新增绑定必须先授权。
model HostingSeat {
id String @id @default(dbgenerated("app.uuidv7()")) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
accountId String? @unique @map("account_id") @db.Uuid
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
authorizedUserId String? @map("authorized_user_id") @db.Uuid
bindExpiresAt DateTime? @map("bind_expires_at") @db.Timestamptz(3)
reservationId String? @unique @map("reservation_id")
deviceId String? @map("device_id")
reservedAt DateTime? @map("reserved_at") @db.Timestamptz(3)
note String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@index([tenantId, authorizedUserId])
@@map("hosting_seats")
@@schema("app")
}
/// 用户角色授权。角色权限映射仍由 common/permissions.ts 定义。
model UserRoleGrant {
id String @id @default(dbgenerated("app.uuidv7()")) @db.Uuid
userId String @map("user_id") @db.Uuid
scope String
tenantId String? @map("tenant_id") @db.Uuid
role String
source String @default("manual")
grantedBy String? @map("granted_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, scope], map: "uq_role_grant_user_scope")
@@index([tenantId, role])
@@map("user_role_grants")
@@schema("app")
}
import { PlatformModule } from './modules/platform/platform.module';
import { IntegrationAccessGuard } from './modules/integration/integration-access.service';
import { IntegrationModule } from './modules/integration/integration.module';
import { Module } from '@nestjs/common';
......@@ -45,6 +46,7 @@ import { HealthController } from './health.controller';
// ⚠️ @Global():摄入侧发布、工作台订阅,必须是同一个实例
RealtimeModule,
AuthModule,
PlatformModule,
IntegrationModule,
ChannelModule,
MediaModule,
......
import 'reflect-metadata';
import { z } from 'zod';
import { createCliApp } from './cli-app';
import { describeCliError } from './cli-error';
import { PrismaService } from '../prisma/prisma.service';
import { HostingAdminService } from '../modules/hosting/hosting-admin.service';
/** 运维入口,不开放给租户管理员;时间统一要求带时区的 ISO 格式。 */
async function main() {
const args = process.argv.slice(2).filter((x) => x !== '--');
const command = args.shift();
if (!command || command === 'help') {
console.log(
'ai:hosting list|grant|renew|authorize|revoke --tenant=<AI租户UUID> [--seat=<UUID>] [--user=<UUID>] [--account=<UUID>] [--expires=<ISO时间>|--permanent] [--bind-hours=24] --reason=<说明>',
);
console.log(
'grant 默认一年;renew 默认从现有到期日与当前时间的较晚者延长一年。authorize 只对空席位;已请求二维码须先核实供应商状态,再带 --reset-binding。',
);
return;
}
if (!['list', 'grant', 'renew', 'authorize', 'revoke'].includes(command))
throw new Error('未知命令');
const options = new Map<string, string>();
const allowed = [
'tenant',
'seat',
'user',
'account',
'expires',
'permanent',
'bind-hours',
'reason',
'reset-binding',
];
for (const arg of args) {
const match = /^--([a-z-]+)(?:=(.*))?$/.exec(arg);
if (!match || !allowed.includes(match[1]!) || options.has(match[1]!))
throw new Error(`无效或重复参数:${arg}`);
options.set(match[1]!, match[2] ?? 'true');
}
const tenantId = z.uuid().parse(options.get('tenant'));
const app = await createCliApp({ logger: ['warn', 'error'] });
const db = app.get(PrismaService);
try {
await db.tenant.findUniqueOrThrow({ where: { id: tenantId } });
const result =
command === 'list'
? await db.hostingSeat.findMany({ where: { tenantId }, orderBy: { createdAt: 'asc' } })
: await app
.get(HostingAdminService)
.execute(command, Object.fromEntries(options), { ref: 'cli:hosting-seat' });
console.log(JSON.stringify(result, null, 2));
} finally {
await app.close();
}
}
main().catch((e) => {
console.error(describeCliError(e));
process.exitCode = 1;
});
......@@ -151,9 +151,11 @@ export function scanLines(nodes: readonly OrgNodeWithRefs[]): string[] {
*/
export async function setDataScope(
tx: Prisma.TransactionClient,
u: { id: string; tenantId: string },
u: { id: string; tenantId: string | null },
to: 'tenant' | 'scoped',
): Promise<{ changed: boolean; from: string; to: string; rev: number }> {
if (!u.tenantId) throw new Error('平台账号不能设置客户组织范围');
const rev0 = await lockOrg(tx, u.tenantId);
const cur = await tx.user.findFirstOrThrow({
where: { id: u.id, tenantId: u.tenantId },
......
......@@ -46,6 +46,7 @@ async function main() {
});
if (users.length !== 1) throw new Error(`"${who}" 匹配到 ${users.length} 个用户`);
const u = users[0]!;
if (!u.tenantId) throw new Error('平台账号没有患者数据范围');
const user: AuthUser = {
id: u.id,
tenantId: u.tenantId,
......
import 'reflect-metadata';
import { createCliApp } from './cli-app';
import { describeCliError } from './cli-error';
import { PlatformAdminService } from '../modules/platform/platform-admin.service';
async function main() {
const [command, ...args] = process.argv.slice(2).filter((x) => x !== '--');
if (!command || command === 'help') {
console.log(
'ai:platform grant|revoke --user=<用户UUID> --role=platform_admin|platform_viewer --reason=<原因>\n新账号:grant --phone=<手机号> --name=<姓名> --reason=<原因>',
);
return;
}
if (!['grant', 'revoke'].includes(command)) throw new Error('命令无效');
const options: Record<string, string> = {};
for (const arg of args) {
const m = /^--(user|phone|name|role|reason)=(.+)$/.exec(arg);
if (!m || options[m[1]!]) throw new Error('参数无效或重复');
options[m[1]!] = m[2]!;
}
const app = await createCliApp({ logger: ['warn', 'error'] });
try {
console.log(
JSON.stringify(
await app
.get(PlatformAdminService)
.execute({
userId: options.user,
phone: options.phone,
name: options.name,
role: options.role,
revoke: command === 'revoke',
reason: options.reason ?? '',
}),
null,
2,
),
);
} finally {
await app.close();
}
}
main().catch((e) => {
console.error(describeCliError(e));
process.exitCode = 1;
});
import { setTenantRole } from '../common/role-grants';
import { customerRoot } from './customer-root';import 'reflect-metadata';
import { createCliApp } from './cli-app';
import { describeCliError } from './cli-error';
......@@ -160,6 +161,7 @@ async function main() {
* ⭐ 但**写审计**:角色变更正是最该留痕的那类操作。
*/
if (val('role-of')) {
if (!u.tenantId) throw new Error('平台角色请用 ai:platform 管理');
const role = val('role');
if (role !== 'owner' && role !== 'staff' && role !== 'viewer') {
throw new Error(`--role 只能是 owner / staff / viewer,收到 ${role ?? '(空)'}`);
......@@ -170,10 +172,11 @@ async function main() {
}
const before = u.role;
await prisma.$transaction(async (tx) => {
await tx.user.update({ where: { id: u.id }, data: { role, integrationManagedRole: false } });
await setTenantRole(tx, u.tenantId!, u.id, role, 'manual');
await tx.auditLog.create({
data: {
tenantId: u.tenantId,
scope: 'platform',
action: 'user.role',
objectType: 'user',
objectId: u.id,
......
import { SetMetadata } from '@nestjs/common';
/** 默认接口只允许客户会话;仅显式标注的接口可接收平台会话。 */
export const AUTH_SCOPE = 'authScope';
export const AuthScope = (scope: 'tenant' | 'platform') => SetMetadata(AUTH_SCOPE, scope);
import { AUTH_SCOPE } from '../decorators/auth-scope.decorator';
import type { HostScope } from '../../modules/integration/integration.schema';
import { SessionService } from '../../modules/auth/session.service';
import {
......@@ -17,6 +18,8 @@ import { REQUIRE_PERMISSION } from '../decorators/require-permission.decorator';
import { isReadOnly, permissionsOf, type Permission } from '../permissions';
import { ALLOW_VIEWER_WRITE } from '../decorators/viewer-write.decorator';
export interface PlatformUser { id: string; displayName: string; role: string; permissions: readonly Permission[]; sessionId: string; mustChangePassword: boolean; }
export interface AuthUser {
id: string;
tenantId: string;
......@@ -68,7 +71,7 @@ export class JwtAuthGuard implements CanActivate {
]);
if (isPublic) return true;
const req = ctx.switchToHttp().getRequest<Request & { user?: AuthUser }>();
const req = ctx.switchToHttp().getRequest<Request & { user?: AuthUser; platformUser?: PlatformUser }>();
const token = extractToken(req);
if (!token) throw new UnauthorizedException('请先登录');
......@@ -104,7 +107,11 @@ export class JwtAuthGuard implements CanActivate {
});
// ⚠️ 三个条件缺一不可,而且**每一个都对应一种真实的撤销需求**
if (!user) throw new UnauthorizedException('账号不存在,请重新登录');
if (user.tenantId !== session.tenantId || user.tenant.status !== 'active')
const scope = this.reflector.getAllAndOverride<string>(AUTH_SCOPE, [ctx.getHandler(), ctx.getClass()]) ?? 'tenant';
if (session.scope !== scope) throw new NotFoundException('当前会话不能访问此工作区');
const grant = await this.prisma.userRoleGrant.findUnique({ where: { userId_scope: { userId: user.id, scope: session.scope } } });
if (!grant || (session.scope === 'tenant' && grant.tenantId !== user.tenantId)) throw new UnauthorizedException('授权已撤销');
if (session.scope === 'tenant' && (!user.tenantId || user.tenantId !== session.tenantId || user.tenant?.status !== 'active'))
throw new UnauthorizedException('客户已停用或会话已失效');
if (!user.isActive) throw new UnauthorizedException('账号已停用,需要的话请联系管理员');
if (user.tokenVersion !== claims.ver) throw new UnauthorizedException('登录已失效,请重新登录');
......@@ -138,7 +145,7 @@ export class JwtAuthGuard implements CanActivate {
* ⚠️ 拒绝时回 `NotFound` 而不是 `Forbidden`:403 等于告诉对方
* "这个接口是真的,只是你不够格" —— 那本身也是信息(同会话/待办那两处的理由)。
*/
const permissions = session.hostScope ? permissionsOf(user.role).filter((p) => session.hostScope!.capabilities.includes(p)) : permissionsOf(user.role);
const permissions = session.hostScope ? permissionsOf(grant.role).filter((p) => session.hostScope!.capabilities.includes(p)) : permissionsOf(grant.role);
const need = this.reflector.getAllAndOverride<Permission[]>(REQUIRE_PERMISSION, [
ctx.getHandler(),
ctx.getClass(),
......@@ -164,7 +171,7 @@ export class JwtAuthGuard implements CanActivate {
* ⛔ 不在这道闸的范围里,也不该在:它不改变任何业务事实。
* ⚠️ 拒绝同样回 404 语义(同上面那道闸的理由)。
*/
if ((isReadOnly(user.role) || session.hostScope?.readOnly) && !READ_METHODS.has(req.method)) {
if ((isReadOnly(grant.role) || session.hostScope?.readOnly) && !READ_METHODS.has(req.method)) {
const allowed = this.reflector.getAllAndOverride<boolean>(ALLOW_VIEWER_WRITE, [
ctx.getHandler(),
ctx.getClass(),
......@@ -172,13 +179,19 @@ export class JwtAuthGuard implements CanActivate {
if (!allowed) throw new NotFoundException('请求的功能不存在,请刷新页面后再试');
}
if (session.scope === 'platform') {
if (session.source !== 'password' || session.hostScope) throw new UnauthorizedException('平台管理需要密码登录');
req.platformUser = { id: user.id, displayName: user.displayName, role: grant.role, permissions, sessionId: session.id, mustChangePassword: user.mustChangePassword };
return true;
}
if (!user.tenantId) throw new UnauthorizedException('未绑定客户');
const authUser: AuthUser = {
sessionId: session.id,
accessExpiresAt: claims.exp * 1000,
id: user.id,
tenantId: user.tenantId,
displayName: user.displayName,
role: user.role,
role: grant.role,
permissions,
dataScope: session.hostScope && !session.hostScope.accountAll ? 'scoped' : user.dataScope,
hostScope: session.hostScope,
......
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import type { Request } from 'express';
import type { Observable } from 'rxjs';
import { Observable } from 'rxjs';
import { runWithTenant } from '../tenant-context';
import type { AuthUser } from '../guards/jwt-auth.guard';
......@@ -9,7 +9,7 @@ import type { AuthUser } from '../guards/jwt-auth.guard';
*
* ⚠️ **必须是拦截器,不能是守卫** —— AsyncLocalStorage 的上下文只在传入的回调内有效,
* 而守卫返回后才轮到业务代码跑。拦截器的 `next.handle()` 就是那个 continuation,
* 包住它才能让后面所有 Prisma 读都看得到租户。
* 必须把订阅也放进 run(),后续 Prisma 读才能看得到租户。
* 写成守卫的后果是**纵深防御静默失效**:不报错、不告警,只是不再注入 tenantId。
*
* ⚠️ 注册顺序:必须排在 JwtAuthGuard **之后**(守卫先于拦截器执行,这一点 Nest 保证),
......@@ -22,7 +22,7 @@ export class TenantScopeInterceptor implements NestInterceptor {
const user = req.user;
// @Public() 端点没有 user —— 不铺上下文,守卫扩展会照常放行(那是系统任务的语义)
if (!user) return next.handle();
return runWithTenant({ tenantId: user.tenantId, userId: user.id,
...(user.hostScope && user.sessionId && req.hostCapability ? { hostAuthorization: { sessionId: user.sessionId, capability: req.hostCapability } } : {}) }, () => next.handle());
return new Observable(subscriber => runWithTenant({ tenantId: user.tenantId, userId: user.id,
...(user.hostScope && user.sessionId && req.hostCapability ? { hostAuthorization: { sessionId: user.sessionId, capability: req.hostCapability } } : {}) }, () => next.handle().subscribe(subscriber)));
}
}
......@@ -32,6 +32,9 @@
* 「勾同事开户」和「直接填姓名建人」是两个接口、**同一个能力**。
*/
export const PERMISSIONS = {
PLATFORM_VIEW: 'platform.view',
PLATFORM_HOSTING_MANAGE: 'platform.hosting.manage',
PLATFORM_AUDIT_VIEW: 'platform.audit.view',
APPOINTMENT_CREATE: 'appointment.create',
APPOINTMENT_CONFIRM: 'appointment.confirm',
APPOINTMENT_RESCHEDULE: 'appointment.reschedule',
......@@ -146,6 +149,8 @@ export const ADMIN_PERMISSIONS: readonly Permission[] = [
* 未知角色的正确处理是**什么都不给**,见 `permissionsOf`。
*/
const ROLE_PERMISSIONS: Record<string, readonly Permission[]> = {
platform_admin: [PERMISSIONS.PLATFORM_VIEW, PERMISSIONS.PLATFORM_HOSTING_MANAGE, PERMISSIONS.PLATFORM_AUDIT_VIEW],
platform_viewer: [PERMISSIONS.PLATFORM_VIEW, PERMISSIONS.PLATFORM_AUDIT_VIEW],
owner: [
PERMISSIONS.APPOINTMENT_CREATE,
PERMISSIONS.APPOINTMENT_CONFIRM,
......@@ -206,5 +211,5 @@ export function hasPermission(
* 而这个文件的全部意义就是让那种改动只发生在**一处**。
*/
export function isReadOnly(role: string): boolean {
return role === 'viewer';
return role === 'viewer' || role === 'platform_viewer';
}
import type { Prisma } from '../../generated/prisma';
/** 修改租户角色的统一入口;users.role 由数据库投影,不允许另行写入。 */
export async function setTenantRole(
db: Prisma.TransactionClient,
tenantId: string,
userId: string,
role: string,
source: 'manual' | 'host',
grantedBy?: string,
) {
if (!['owner', 'staff', 'viewer'].includes(role)) throw new Error('租户角色无效');
await db.user.findFirstOrThrow({ where: { id: userId, tenantId } });
return db.userRoleGrant.upsert({
where: { userId_scope: { userId, scope: 'tenant' } },
create: { userId, tenantId, scope: 'tenant', role, source, grantedBy },
update: { role, source, grantedBy },
});
}
......@@ -125,7 +125,7 @@ export interface AppConfig {
urlTtlSeconds: number;
};
friendAdd: { dailyQuota: number; minGapSec: number };
outboundAsset: { publicBaseUrl: string; ttlSeconds: number; maxUploadBytes: number };
outboundAsset: { maxUploadBytes: number };
debugTrace: { enabled: boolean; days: number };
}
......@@ -334,17 +334,6 @@ export function loadConfig(): AppConfig {
minGapSec: Number(process.env.AI_FRIEND_ADD_MIN_GAP_SEC ?? 30),
},
/**
* 出站附件 —— **发图片/文件给患者**要用。
*
* ⚠️⚠️ `publicBaseUrl` 是**硬依赖**:ECom 的所有上传/发送接口只收 URL
* (`[11030]/[11031]` 的 `url`、`[201000]` 的 `file`),没有 base64 也没有 multipart。
* ⇒ 我们必须给出一个**ECom 够得着**的地址。
* ⭐ 生产上这个地址本来就有:回调 `POST /api/channel/ecom/callback` 就走它
* ([03 D13] 生产由 ECom 直推 ai-service)。填同一个域名即可。
* ⚠️ **本地开发没有公网地址**(这正是中转存在的理由)⇒ 本地发图片会失败在
* "ECom 取不到那个 URL"。这是环境限制不是 bug,起服务时会喊一句。
*/
/**
* ⭐⭐ 模型活动调试记录(`llm_traces`,2026-09-29)—— 输入全文、思考、工具调用和返回、输出,按顺序存。
* ⚠️ 里面有患者对话和查到的患者信息 ⇒ 只作调试:默认留 7 天,`AI_DEBUG_TRACE=off` 整个关掉。
*/
......@@ -353,9 +342,6 @@ export function loadConfig(): AppConfig {
days: Math.max(1, Number(process.env.AI_DEBUG_TRACE_DAYS ?? 7) || 7),
},
outboundAsset: {
publicBaseUrl: (process.env.AI_PUBLIC_BASE_URL ?? '').replace(/\/+$/, ''),
// 10 分钟:够 ECom 取一次,又短到丢了也没多大用
ttlSeconds: Number(process.env.AI_ASSET_TTL_SECONDS ?? 600),
// 20MB。⚠️ 比 body-parser 的 2mb 大得多 —— 上传走 multipart 不过 body-parser
maxUploadBytes: Number(process.env.AI_MAX_UPLOAD_BYTES ?? 20 * 1024 * 1024),
},
......
import { hostingAllowed, HOSTING_UNAVAILABLE } from '../../hosting/hosting-seat.service';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { mediaReadinessOf } from '../../media/media-readiness';
......@@ -171,6 +172,7 @@ export class AgentInboundHandler implements OutboxHandler {
}
const accountId = await this.accountOf(runOn);
if (accountId && !await hostingAllowed(this.prisma, task.tenantId, accountId)) return { ok: true, status: 'canceled', reason: HOSTING_UNAVAILABLE };
if (accountId) this.publish(task.tenantId, accountId, runOn);
try {
......
......@@ -37,7 +37,7 @@ export class ActivationService {
/** 打开激活页时先问一下:这张票还有效吗、是给谁的。 */
async peek(token: string): Promise<{ displayName: string; tenantName: string }> {
const row = await this.live(token);
return { displayName: row.user.displayName, tenantName: row.user.tenant.name };
return { displayName: row.user.displayName, tenantName: row.user.tenant!.name };
}
/**
......@@ -70,6 +70,9 @@ export class ActivationService {
const hash = await this.pw.hash(input.password);
await this.prisma.$transaction(async (tx) => {
// 与平台提权共用账号行锁,避免旧激活票和提权并发修改同一登录身份。
await tx.$queryRaw`SELECT id FROM app.users WHERE id=${row.userId}::uuid FOR UPDATE`;
if (await tx.userRoleGrant.findUnique({where:{userId_scope:{userId:row.userId,scope:'platform'}}})) throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '平台账号不能使用客户激活链接');
/**
* ⚠️⚠️ **票要在同一个事务里作废,而且带条件**。
* `used_at IS NULL` 写进 WHERE ⇒ 两个人同时点同一条链接时,
......@@ -115,7 +118,7 @@ export class ActivationService {
return {
userId: row.userId,
displayName: row.user.displayName,
tenantName: row.user.tenant.name,
tenantName: row.user.tenant!.name,
tokenVersion: u.tokenVersion,
};
}
......@@ -138,9 +141,10 @@ export class ActivationService {
user: { select: { displayName: true, isActive: true, tenant: { select: { name: true } } } },
},
});
if (!row || row.usedAt || row.expiresAt.getTime() < Date.now() || !row.user.isActive) {
if (!row || row.usedAt || row.expiresAt.getTime() < Date.now() || !row.user.isActive || !row.user.tenant) {
throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '链接无效或已过期');
}
if (await this.prisma.userRoleGrant.findUnique({where:{userId_scope:{userId:row.userId,scope:'platform'}}})) throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '平台账号不能使用客户激活链接');
return row;
}
}
......
import { HostingSeatService } from '../hosting/hosting-seat.service';
import { IntegrationAccessService } from '../integration/integration-access.service';
import { SessionService } from './session.service';
import { ConfigService } from '@nestjs/config';
import {
BadRequestException,
UnauthorizedException,
......@@ -35,9 +35,6 @@ import { humanHoursOf } from '../workbench/human-hours.schema';
@Controller('auth')
export class AuthController {
/** 见 me() 里 capabilities 的说明 */
private readonly canSendMedia: boolean;
constructor(
private readonly prisma: PrismaService,
private readonly qr: QrLoginService,
......@@ -49,11 +46,8 @@ export class AuthController {
private readonly throttle: LoginThrottleService,
private readonly activation: ActivationService,
private readonly profile: ProfileService,
config: ConfigService,
) {
this.canSendMedia = !!config.getOrThrow<{ publicBaseUrl: string }>('outboundAsset')
.publicBaseUrl;
}
private readonly seats: HostingSeatService,
) {}
/**
* 手机号 + 密码登录 —— **主登录路径**。
......@@ -73,12 +67,13 @@ export class AuthController {
@Post('login')
@HttpCode(200)
async login(
@Body() body: { phone?: string; password?: string },
@Body() body: { phone?: string; password?: string; scope?: 'tenant' | 'platform' },
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const r = await this.passwords.login(String(body.phone ?? ''), String(body.password ?? ''));
const tokens = await this.sessions.create(r.userId, 'password', r.tokenVersion);
if (body.scope && !['tenant', 'platform'].includes(body.scope)) throw new BadRequestException('登录范围无效');
const r = await this.passwords.login(String(body.phone ?? ''), String(body.password ?? ''), body.scope ?? 'tenant');
const tokens = await this.sessions.create(r.userId, 'password', r.tokenVersion, body.scope ?? 'tenant');
res.clearCookie(AUTH_COOKIE, { path: '/' });
/**
* ⭐ 把这个来源记成"熟客" —— 全局限流闸对它豁免。
......@@ -357,8 +352,10 @@ export class AuthController {
* ⚠️ **补全形状**再给前端。⛔ 别原样透传那一列:`null` 和 `{}` 在录入页那头
* 是两套分支,而它们在这里是同一件事(没设过)。
*/
const hosting = await this.seats.accounts(user.tenantId, accounts.map((a) => a.id));
const accountsOut = accounts.map(({ persona, humanHours, ...a }) => ({
...a,
hosting: hosting.get(a.id) ?? { active: false, expiresAt: null },
persona: personaOf(persona),
// ⚠️ 同 persona:库里那一列可能是上一版的形状或脏数据 ⇒ 解析不出来就当**没排班**,
// ⛔ 别把半截对象甩给录入页。方向(偏向"AI 照常在")见 `humanHoursOf`
......@@ -424,20 +421,10 @@ export class AuthController {
account: accountsOut[0] ?? null,
accounts: accountsOut,
clinics,
/**
* ⭐ 这个部署**能不能发图片/文件**。
*
* ⚠️⚠️ 判据是 `AI_PUBLIC_BASE_URL` 配没配 —— ECom 的**所有**上传/发送接口
* 都只收 URL(全部文档扫过:没有 base64、没有 multipart,`file`/`url` 一条地址),
* ⇒ 没有公网地址就**根本发不出去**。
*
* ⭐ 做成**配置驱动**而不是在前端写死隐藏:配上那个 env 按钮自己就出来了,
* ⛔ 不用有人记得回来把注释掉的代码放开 —— 那种"记得"从来靠不住。
* ⚠️ 这是**能力声明不是权限** —— 后端该拦的照拦(附件上传要登录、
* 发送要过 detail() 的范围判定)。前端拿它只决定"画不画那颗按钮"。
*/
// OSS 是启动必需项,所有环境都具备发送媒体能力;发送权限仍由业务接口校验。
hosting: await this.seats.summary(user.tenantId, user.id),
capabilities: {
sendMedia: this.canSendMedia,
sendMedia: true,
},
};
}
......
......@@ -34,6 +34,6 @@ import { ProfileService } from './profile.service';
// ⛔ 不注册成 APP_GUARD:它只该管登录那两条路,全局挂上会连轮询接口一起限。
LoginThrottleGuard,
],
exports: [LoginThrottleGuard, LoginThrottleService, IntegrationRegistryService, IntegrationPolicyService, SessionService, JwtService, QrLoginService, PasswordService],
exports: [PasswordLoginService, LoginThrottleGuard, LoginThrottleService, IntegrationRegistryService, IntegrationPolicyService, SessionService, JwtService, QrLoginService, PasswordService],
})
export class AuthModule {}
......@@ -45,7 +45,7 @@ export class PasswordLoginService {
private readonly pw: PasswordService,
) {}
async login(rawPhone: string, password: string): Promise<LoginResult> {
async login(rawPhone: string, password: string, scope: 'tenant' | 'platform' = 'tenant'): Promise<LoginResult> {
const phone = toE164(rawPhone);
if (!phone || !password) {
throw new BizError(AiApiCode.AUTH_INVALID_CREDENTIALS, '手机号或密码不正确');
......@@ -63,7 +63,7 @@ export class PasswordLoginService {
// ⏱️ 锁定检查在**算哈希之前** —— 锁着的账号不该再消耗 128MB 去算一次
if (user?.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
const mins = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60_000);
await this.audit(user.tenantId, user.id, 'locked', phone);
await this.audit(user.tenantId, user.id, 'locked', phone, 'auth.login', scope);
throw new BizError(
AiApiCode.AUTH_PERMISSION_DENIED,
`密码连续输错,账号已锁定,请 ${mins} 分钟后再试,或联系管理员重置`,
......@@ -89,7 +89,7 @@ export class PasswordLoginService {
await this.onFailure(
user.id, user.tenantId, user.failedAttempts, phone,
// ⚠️ 停用的人密码对也不放行,但审计里要分得清 —— 对外是同一句话,对内不是
user.isActive && user.passwordHash ? 'bad_password' : 'inactive',
user.isActive && user.passwordHash ? 'bad_password' : 'inactive', scope,
);
}
else this.logger.warn(`登录失败:手机号不存在 ${maskPhone(phone)}`);
......@@ -101,12 +101,12 @@ export class PasswordLoginService {
where: { id: user.id },
data: { failedAttempts: 0, lockedUntil: null },
});
await this.audit(user.tenantId, user.id, 'ok', phone);
await this.audit(user.tenantId, user.id, 'ok', phone, 'auth.login', scope);
const tenant = await this.prisma.tenant.findUniqueOrThrow({
const tenant = user.tenantId ? await this.prisma.tenant.findUniqueOrThrow({
where: { id: user.tenantId },
select: { name: true },
});
}) : { name: '平台管理' };
this.logger.log(`登录成功 ${user.displayName} ${maskPhone(phone)}`);
return {
userId: user.id,
......@@ -124,7 +124,7 @@ export class PasswordLoginService {
* ⭐ 改完 `token_version + 1`:这是 [11 §6.4] 里唯一的"作废全部旧令牌"机制。
* 改密码的常见动机就是"我怀疑被盗了",不作废旧令牌等于白改。
*/
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
async changePassword(userId: string, oldPassword: string, newPassword: string, scope: 'tenant' | 'platform' = 'tenant'): Promise<void> {
const bad = this.pw.checkStrength(newPassword);
if (bad) throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, bad);
if (oldPassword === newPassword) {
......@@ -151,17 +151,18 @@ export class PasswordLoginService {
tokenVersion: { increment: 1 },
},
});
await this.audit(user.tenantId, user.id, 'ok', null, 'auth.password.change');
await this.audit(user.tenantId, user.id, 'ok', null, 'auth.password.change', scope);
this.logger.log(`密码已修改 userId=${userId} —— 该用户全部旧令牌已作废`);
}
/** ⚠️ 失败计数**在数据库里自增**,不是先读后写 —— 并发爆破下先读后写会漏计 */
private async onFailure(
userId: string,
tenantId: string,
tenantId: string | null,
current: number,
phone: string,
reason: 'bad_password' | 'inactive',
scope: 'tenant' | 'platform',
) {
const next = current + 1;
const lock = next >= MAX_FAILED;
......@@ -172,7 +173,7 @@ export class PasswordLoginService {
...(lock ? { lockedUntil: new Date(Date.now() + LOCK_MS) } : {}),
},
});
await this.audit(tenantId, userId, lock ? 'locked' : reason, phone);
await this.audit(tenantId, userId, lock ? 'locked' : reason, phone, 'auth.login', scope);
if (lock) {
this.logger.warn(
`🔒 账号已锁定 ${maskPhone(phone)} —— 连续失败 ${next} 次,锁 ${LOCK_MS / 60_000} 分钟`,
......@@ -182,22 +183,24 @@ export class PasswordLoginService {
/**
* 登录审计。
* ⚠️ `audit_logs.tenant_id` 是 NOT NULL,所以**手机号不存在时写不了** ——
* ⚠️ 手机号不存在时不创建审计对象 ——
* 那种情况没有租户可归属,只落应用日志。这不是遗漏:一个不存在的账号,
* 本来也不属于任何一家诊所的审计范围。
* ⛔ 永远不记密码,手机号也只记掩码。
*/
private async audit(
tenantId: string,
tenantId: string | null,
userId: string,
outcome: 'ok' | 'bad_password' | 'locked' | 'inactive',
phone: string | null,
action = 'auth.login',
scope: 'tenant' | 'platform' = 'tenant',
) {
await this.prisma.auditLog
.create({
data: {
tenantId,
scope: tenantId ? scope : 'platform',
action,
objectType: 'user',
objectId: userId,
......
import { HostingSeatService } from '../hosting/hosting-seat.service';
import { randomUUID } from 'node:crypto';
import { localLoginRoute } from '../channel/ingress-pull.client';
import { Injectable, Logger } from '@nestjs/common';
import { ECOM_PATHS, VERIFY_CODE_RESULT } from '@pac/ecom-client';
import { PrismaService } from '../../prisma/prisma.service';
......@@ -99,6 +102,7 @@ export class QrLoginService {
constructor(
private readonly prisma: PrismaService,
private readonly ecom: EcomService,
private readonly seats: HostingSeatService,
) {}
/**
......@@ -168,6 +172,7 @@ export class QrLoginService {
accounts: { vid: string; nickname: string | null; status: string; region: string }[];
/** ⚠️ 本人已经绑过企微号了吗 —— 决定面板是"首次绑定"还是"重新托管" */
bound: boolean;
hosting: { canBind: boolean; bindExpiresAt: Date | null };
}> {
const tenant = await this.prisma.tenant.findUniqueOrThrow({
where: { id: user.tenantId },
......@@ -200,6 +205,7 @@ export class QrLoginService {
defaultRegion: mine[0]?.region ?? sibling ?? null,
accounts: mine,
bound: !!identity,
hosting: await this.seats.summary(user.tenantId, user.id),
};
}
......@@ -341,7 +347,7 @@ export class QrLoginService {
// 现在人已经登录,这个歧义从根上没有了。
const accounts = await this.prisma.wecomAccount.findMany({
where: { tenantId: tenant.id, userId: user.id },
select: { vid: true, deviceId: true, region: true, platformType: true },
select: { id: true, vid: true, deviceId: true, region: true, platformType: true },
orderBy: { createdAt: 'asc' },
});
/**
......@@ -418,6 +424,7 @@ export class QrLoginService {
* 而且两个同事同时开一轮是合法的,按 vid 收敛不了。
*/
if (reuse) {
await this.seats.assertAccount(tenant.id, reuse.id);
const live = await this.liveRoundFor(tenant.id, reuse.vid);
if (live) {
this.logger.log(
......@@ -428,9 +435,13 @@ export class QrLoginService {
}
}
const reservation = reuse ? null : await this.seats.reserve(tenant.id, user.id);
if (reservation?.deviceId) return { deviceId: reservation.deviceId, reused: true };
const traceId = reservation?.reservationId ?? `ai-login-${randomUUID()}`;
await localLoginRoute({ traceId });
const res = await this.ecom.platformCallRaw(ECOM_PATHS.wechatwork, {
type: 11028,
traceId: `ai-login-${Date.now()}`,
traceId,
// ⭐ 传了这两个 = 告诉 ECom「用这个号原来那台设备」。少传任何一个都算新设备。
...(reuse ? { userId: reuse.vid, deviceId: reuse.deviceId } : {}),
data: {
......@@ -448,6 +459,8 @@ export class QrLoginService {
const deviceId = String(res.deviceId ?? '');
if (!deviceId) throw new BizError(AiApiCode.ECOM_RETURNED_ERROR, '[11028] 企业微信没有返回登录信息,请重新获取二维码');
await localLoginRoute({ traceId, deviceId });
// ⚠️ 把租户记在这一轮扫码上 —— 不能等成功事件回来再问前端"你是哪个租户",
// 那就又变成"前端说自己是谁"了。这里借 sync_cursors 之外的最轻方式:
// 直接写进本次的 webhook_deliveries 之前是不可能的(事件还没来),
......@@ -473,6 +486,7 @@ export class QrLoginService {
},
},
});
if (reservation) await this.seats.attachDevice(tenant.id, user.id, traceId, deviceId);
this.logger.log(
`扫码登录已下单 deviceId=${deviceId} tenant=${tenant.id} region=${region} ` +
`复用=${reuse ? reuse.vid : '否(新设备)'}`,
......@@ -563,6 +577,7 @@ export class QrLoginService {
* ⛔ 但**不能**简单调大 —— 调大就把上面那个串轮问题放回来了。
* ⇒ 正解是别走到兜底:audit 行是 start() 必写的,走到这里说明那一轮不是我们开的。
*/
await this.seats.assertRound(user.tenantId, user.id, deviceId);
let floor = since ?? null;
/**
* 本轮的意图(谁开的、要复用哪个号)。
......@@ -974,6 +989,9 @@ export class QrLoginService {
const d = (payload.data ?? payload) as Record<string, unknown>;
const vid = str(d.userId) ?? str(payload.userId);
if (!vid) throw new BizError(AiApiCode.AUTH_INVALID_CREDENTIALS, '[11026] 事件里没有 userId');
try { await localLoginRoute({ deviceId, accountId: vid }); } catch {
throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '该企业微信号属于其他环境或扫码会话已过期,请重新获取二维码');
}
const intent = await this.prisma.$queryRaw<
{
......@@ -1115,6 +1133,9 @@ export class QrLoginService {
},
});
if (row.reuse_vid) await this.seats.assertAccount(tenantId, acct.id, tx);
else await this.seats.consume(tx, tenantId, user.id, deviceId, acct.id);
// ⭐⭐ 首轮同步:绑上就拉通讯录、会话和群,⛔ 等定时任务(见 `enqueueBindSync`)
await enqueueBindSync(tx, { tenantId, accountId: acct.id, vid, deviceId, roundStartedAt: startedAt });
......
......@@ -17,7 +17,8 @@ import { permissionsOf } from '../../common/permissions';
export interface SessionTokens {
sessionId: string;
userId: string;
tenantId: string;
tenantId: string | null;
scope?: string;
accessToken: string;
accessExpiresAt: string;
refreshToken: string;
......@@ -46,6 +47,7 @@ export class SessionService {
userId: string,
source: 'password' | 'activation' | 'development' = 'password',
expectedVersion?: number,
scope: 'tenant' | 'platform' = 'tenant',
): Promise<SessionTokens> {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
......@@ -59,8 +61,11 @@ export class SessionService {
});
if (expectedVersion !== undefined && user.tokenVersion !== expectedVersion)
throw new UnauthorizedException('登录信息已改变,请重新登录');
if (!user.isActive || user.tenant.status !== 'active')
if (!user.isActive || (scope === 'tenant' && (!user.tenantId || user.tenant?.status !== 'active')))
throw new UnauthorizedException('账号或客户已停用');
if (scope === 'platform' && source !== 'password') throw new UnauthorizedException('平台管理需要密码登录');
const grant = await this.prisma.userRoleGrant.findUnique({ where: { userId_scope: { userId, scope } } });
if (!grant || (scope === 'tenant' && grant.tenantId !== user.tenantId)) throw new UnauthorizedException('没有该工作区的访问权限');
const now = new Date();
const expiresAt = new Date(now.getTime() + this.absoluteSeconds * 1000);
const id = randomUUID();
......@@ -77,7 +82,8 @@ export class SessionService {
const row = await this.prisma.authSession.create({
data: {
id,
tenantId: user.tenantId,
tenantId: scope === 'tenant' ? user.tenantId : null,
scope,
userId,
source,
tokenVersion: user.tokenVersion,
......@@ -108,7 +114,7 @@ export class SessionService {
if (hint?.source === 'host') {
const ctx = hint.integrationContext as unknown as HostSessionContext;
if (!ctx?.appId) return null;
await integrationLock(tx, ctx.appId, hint.tenantId);
await integrationLock(tx, ctx.appId, hint.tenantId!);
}
await tx.$queryRaw`SELECT id FROM app.auth_sessions WHERE id = ${claims.sid}::uuid FOR UPDATE`;
const row = await tx.authSession.findUnique({ where: { id: claims.sid } });
......@@ -132,13 +138,18 @@ export class SessionService {
if (
!user ||
!user.isActive ||
user.tenantId !== row.tenantId ||
(row.scope === 'tenant' && user.tenantId !== row.tenantId) ||
user.tokenVersion !== row.tokenVersion ||
user.tenant.status !== 'active'
(row.scope === 'tenant' && user.tenant?.status !== 'active')
) {
await tx.authSession.update({ where: { id: row.id }, data: { revokedAt: now } });
return null;
}
const grant = await tx.userRoleGrant.findUnique({ where: { userId_scope: { userId: row.userId, scope: row.scope } } });
if (!grant || (row.scope === 'tenant' && grant.tenantId !== row.tenantId)) {
await tx.authSession.update({ where: { id: row.id }, data: { revokedAt: new Date() } });
return null;
}
if (row.source === 'host') {
if (!this.integration) return null;
try { await this.integration.context(row, tx); } catch {
......@@ -239,7 +250,8 @@ export class SessionService {
select: { isActive: true, tokenVersion: true, tenant: { select: { status: true } } },
});
return (
!!user?.isActive && user.tokenVersion === row.tokenVersion && user.tenant.status === 'active'
!!user?.isActive && user.tokenVersion === row.tokenVersion && (row.scope === 'platform' || user.tenant?.status === 'active') &&
!!(await this.prisma.userRoleGrant.findUnique({ where: { userId_scope: { userId, scope: row.scope } } }))
);
}
......@@ -267,10 +279,10 @@ export class SessionService {
if (!session || session.source !== 'host' || !this.valid(session) || !this.integration) return null;
const user = await this.prisma.user.findUnique({ where: { id: session.userId }, select: { id: true, tenantId: true, displayName: true, role: true,
dataScope: true, orgUnitId: true, jobTitle: true, mustChangePassword: true, tokenVersion: true, isActive: true } });
if (!user?.isActive || user.mustChangePassword || user.tokenVersion !== session.tokenVersion || user.tenantId !== session.tenantId) return null;
if (!user?.tenantId || !user.isActive || user.mustChangePassword || user.tokenVersion !== session.tokenVersion || user.tenantId !== session.tenantId) return null;
try {
const hostScope = await this.integration.context(session);
return hostScope ? { ...user, sessionId: id, dataScope: hostScope.accountAll ? user.dataScope : 'scoped',
return hostScope ? { ...user, tenantId: user.tenantId, sessionId: id, dataScope: hostScope.accountAll ? user.dataScope : 'scoped',
permissions: permissionsOf(user.role).filter((p) => hostScope.capabilities.includes(p)), hostScope } : null;
} catch (e) { if (e instanceof BizError) await this.revoke(session.id, session.userId); return null; }
}
......@@ -296,6 +308,7 @@ export class SessionService {
sessionId: row.id,
userId: row.userId,
tenantId: row.tenantId,
scope: row.scope,
accessToken: access.token,
accessExpiresAt: access.expiresAt.toISOString(),
refreshToken,
......
import { randomUUID } from 'node:crypto';
import { localLoginRoute } from './ingress-pull.client';
import { Injectable, Logger } from '@nestjs/common';
import {
ACCOUNT_EVENT,
......@@ -345,9 +347,12 @@ export class AccountLifecycleService {
const now = new Date();
try {
const traceId = `ai-verify-${randomUUID()}`;
await localLoginRoute({ traceId });
await localLoginRoute({ traceId, deviceId: acct.deviceId });
await this.ecom.platformCallRaw(ECOM_PATHS.wechatwork, {
type: 11028,
traceId: `ai-verify-${now.getTime()}`,
traceId,
// ⭐ 传 userId + deviceId = 告诉 ECom「这是旧设备重新验证」,不是新设备登录。
// 少传任何一个都会被当成全新设备 → 又是一次风控。
// (文档:「若检测到新设备异常下线,系统会在2分钟内缓存验证标识,
......
import { HostingSeatService } from '../hosting/hosting-seat.service';
import { Module } from '@nestjs/common';
import { AccountReleaseService } from './account-release.service';
import { AccountLifecycleService } from './account-lifecycle.service';
......@@ -29,6 +30,7 @@ import { ScopeModule } from '../scope/scope.module';
imports: [ScopeModule],
controllers: [InboundController],
providers: [
HostingSeatService,
FriendRequestService,
FriendAddService,
InboundService,
......@@ -46,6 +48,7 @@ import { ScopeModule } from '../scope/scope.module';
EcomCallbackGuard,
],
exports: [
HostingSeatService,
FriendRequestService,
FriendAddService,
EcomService,
......
import { HostingSeatService } from '../hosting/hosting-seat.service';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
......@@ -15,9 +16,10 @@ import { AiApiCode } from '../../common/errors/api-codes';
/**
* ECom 调用的服务端包装。
*
* 职责只有两件,多一件都不该在这里:
* 通道调用边界:
* ① 从配置装出 `EcomClient`(凭据不进包,见 @pac/ecom-client 的说明)
* ② 把 ECom 的**业务错误码**翻译成 friday-ai 的 `AiApiCode`
* ③ 所有账号写操作在外部调用前校验托管席位;读取和主动解除托管除外
*
* ⛔ 不做重试 —— 重试归 `app.outbox` 的认领+租约管([12 §7](docs/12-基础层数据库设计.md))。
* 在这里再加一层会和 outbox 的 attempt 计数对不上,且对写接口造成真正的重复发送。
......@@ -27,7 +29,7 @@ export class EcomService {
private readonly logger = new Logger(EcomService.name);
private readonly client: EcomClient;
constructor(config: ConfigService) {
constructor(config: ConfigService, private readonly seats: HostingSeatService) {
const cfg = config.getOrThrow<{
baseUrl: string;
accessId: string;
......@@ -55,6 +57,9 @@ export class EcomService {
throw new Error(`[EcomService] type=${type} 不在允许调用的白名单里(见 ALLOWED_CALLS)`);
}
const path = ECOM_PATHS[key];
if (![15001, 11062, 11037, 11036, 12001, 13100, 201001, 11039, 16001, 11052].includes(type)) {
await this.seats.assertVid(String(account.userId));
}
try {
res = await this.client.call<T>(path, buildRequest(type, account, data));
......@@ -100,6 +105,8 @@ export class EcomService {
* 后者会让所有调用点都多剥一层,而只有登录这一处需要顶层字段。
*/
async platformCallRaw<T>(path: string, body: Record<string, unknown> = {}): Promise<EcomResponse<T>> {
// 旧设备的自动安全验证也属于重新托管,到期后不能从后台绕过席位限制。
if (Number(body.type) === 11028 && body.userId) await this.seats.assertVid(String(body.userId));
let res: EcomResponse<T>;
try {
res = await this.client.call<T>(path, body);
......@@ -181,7 +188,7 @@ const ALLOWED_CALLS = {
* ⚠️⚠️ [11030] 发图片 / [11031] 发文件 —— 和 [11029] 同一档:**真的发给患者**。
* 调它们之前必须过 GateService 的四层闸,而且只从 outbox 出发(串行 + 随机间隔)。
* ⚠️ 两个都靠 `url` 参数让 ECom **自己去取** —— 那个 URL 由
* `OutboundAssetController.signedUrl()` 现签,10 分钟就废。
* `OutboundAssetService.signedUrl()` 在投递时生成 OSS 短效地址。
*/
11030: 'sendImage',
11031: 'sendFile',
......
......@@ -64,3 +64,22 @@ export async function pullOnce(
if (ack.status !== 204) throw Error(`中转确认失败 HTTP_${ack.status}`);
return true;
}
/** 先登记 traceId 再向 ECom 下单,回调先于同步响应到达也能找到归属。 */
export async function localLoginRoute(
payload: { traceId: string; deviceId?: string } | { deviceId: string; accountId: string },
config = readPullConfig(),
send = fetch,
): Promise<void> {
if (!config) return;
const path = 'traceId' in payload ? '/local/login' : '/local/account';
const response = await send(`${config.url}${path}`, {
method: 'POST',
redirect: 'error',
signal: AbortSignal.timeout(10_000),
headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (response.status === 409) throw Error('该企业微信号属于其他环境或扫码会话已过期');
if (response.status !== 204) throw Error('扫码中转暂不可用,请稍后重试');
}
import { Injectable } from '@nestjs/common';
import { z } from 'zod';
import { Prisma } from '../../../generated/prisma';
import { PrismaService } from '../../prisma/prisma.service';
/** CLI 与平台页面共用,外部入口负责鉴权,变更及审计在同一事务。 */
@Injectable()
export class HostingAdminService {
constructor(private readonly db: PrismaService) {}
async execute(
command: string,
input: Record<string, string>,
actor: { userId?: string; ref: string },
) {
if (!['grant', 'renew', 'authorize', 'revoke'].includes(command)) throw new Error('操作无效');
const options = new Map(Object.entries(input));
const uuid = (key: string) => z.uuid().parse(options.get(key));
const tenantId = uuid('tenant');
const reason = options.get('reason')?.trim();
if (command !== 'list' && (!reason || reason === 'true'))
throw new Error('必须提供 --reason=操作说明');
if (options.has('expires') && options.has('permanent'))
throw new Error('--expires 与 --permanent 不能同时使用');
if (options.has('permanent') && options.get('permanent') !== 'true')
throw new Error('永久席位请使用 --permanent');
if (options.has('reset-binding') && command !== 'authorize')
throw new Error('--reset-binding 仅用于 authorize');
if (options.has('account') && command !== 'grant')
throw new Error('--account 仅用于登记已有账号的 grant');
if (options.has('user') && !['grant', 'authorize'].includes(command))
throw new Error('--user 仅用于 grant/authorize');
const hours = Number(options.get('bind-hours') ?? 24);
if (!Number.isFinite(hours) || hours <= 0 || hours > 168)
throw new Error('绑定授权时长应大于 0 且不超过 168 小时');
const expiry = (previous?: Date | null) => {
if (options.has('permanent')) return null;
if (options.has('expires')) {
const parsed = z.iso.datetime({ offset: true }).parse(options.get('expires'));
const date = new Date(parsed);
if (date.getTime() <= Date.now()) throw new Error('到期时间必须晚于当前时间');
return date;
}
if (command === 'renew' && previous === null)
throw new Error('永久席位无需续期;如需调整请明确 --expires 或 --permanent');
const d = new Date(Math.max(Date.now(), previous?.getTime() ?? 0));
d.setUTCFullYear(d.getUTCFullYear() + 1);
return d;
};
await this.db.tenant.findUniqueOrThrow({ where: { id: tenantId } });
return this.db.$transaction(async (tx) => {
let seat;
if (command === 'grant') {
const accountId = options.has('account') ? uuid('account') : null;
if (accountId)
await tx.wecomAccount.findFirstOrThrow({ where: { id: accountId, tenantId } });
seat = await tx.hostingSeat.create({
data: { tenantId, accountId, expiresAt: expiry(), note: reason },
});
} else {
const id = uuid('seat');
await tx.$queryRaw`SELECT id FROM app.hosting_seats WHERE id=${id}::uuid AND tenant_id=${tenantId}::uuid FOR UPDATE`;
seat = await tx.hostingSeat.findFirstOrThrow({ where: { id, tenantId } });
}
const before = { ...seat };
if (command === 'renew')
seat = await tx.hostingSeat.update({
where: { id: seat.id },
data: { expiresAt: expiry(seat.expiresAt), revokedAt: null },
});
if (command === 'revoke')
seat = await tx.hostingSeat.update({
where: { id: seat.id },
data: {
revokedAt: new Date(),
...(!seat.accountId ? { authorizedUserId: null, bindExpiresAt: null } : {}),
},
});
if (command === 'authorize' || (command === 'grant' && options.has('user'))) {
if (seat.accountId)
throw new Error('该席位已占用,不能换绑;转交员工请使用已有账号转交功能');
if (seat.revokedAt || (seat.expiresAt && seat.expiresAt <= new Date()))
throw new Error('席位未生效,请先续期');
if (seat.reservationId && !options.has('reset-binding'))
throw new Error(
'已向供应商请求二维码,请先核实设备状态,再使用 --reset-binding 重新授权',
);
if (seat.reservedAt && Date.now() - seat.reservedAt.getTime() < 15 * 60_000)
throw new Error('扫码轮次仍在有效窗口内,请至少等待 15 分钟,确认结束后再重新授权');
const userId = uuid('user');
await tx.user.findFirstOrThrow({ where: { id: userId, tenantId, isActive: true } });
const until = new Date(
Math.min(Date.now() + hours * 3600_000, seat.expiresAt?.getTime() ?? Infinity),
);
seat = await tx.hostingSeat.update({
where: { id: seat.id },
data: {
authorizedUserId: userId,
bindExpiresAt: until,
reservationId: null,
reservedAt: null,
deviceId: null,
},
});
}
await tx.auditLog.create({
data: {
tenantId,
action: `hosting.${command}`,
objectType: 'hosting_seat',
objectId: seat.id,
scope: 'platform',
actorType: actor.userId ? 'human' : 'system',
actorUserId: actor.userId,
actorRef: actor.ref,
result: 'ok',
changes: JSON.parse(
JSON.stringify({ reason, before: command === 'grant' ? null : before, after: seat }),
) as Prisma.InputJsonValue,
},
});
return seat;
});
}
}
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../generated/prisma';
import { PrismaService } from '../../prisma/prisma.service';
import { BizError } from '../../common/errors/biz-error';
import { AiApiCode } from '../../common/errors/api-codes';
export const HOSTING_UNAVAILABLE = '企微托管席位未开通或已到期,请联系管理员';
export const activeSeatWhere = () => ({
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
});
export async function hostingAllowed(
db: Pick<Prisma.TransactionClient, 'hostingSeat'>,
tenantId: string,
accountId: string,
) {
return !!(await db.hostingSeat.findFirst({
where: { tenantId, accountId, ...activeSeatWhere() },
select: { id: true },
}));
}
function deny(message: string): never {
throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, message);
}
@Injectable()
export class HostingSeatService {
constructor(private readonly prisma: PrismaService) {}
async assertVid(vid: string) {
const account = await this.prisma.wecomAccount.findUnique({
where: { vid },
select: { id: true, tenantId: true },
});
if (!account) deny(HOSTING_UNAVAILABLE);
await this.assertAccount(account.tenantId, account.id);
}
async assertRound(tenantId: string, userId: string, deviceId: string) {
const account = await this.prisma.wecomAccount.findFirst({
where: { tenantId, userId, deviceId },
select: { id: true },
});
if (account && (await hostingAllowed(this.prisma, tenantId, account.id))) return;
const grant = await this.prisma.hostingSeat.findFirst({
where: {
tenantId,
authorizedUserId: userId,
deviceId,
accountId: null,
bindExpiresAt: { gt: new Date() },
...activeSeatWhere(),
},
});
if (!grant) deny('本次托管授权未开通或已到期,请联系管理员');
}
async summary(tenantId: string, userId: string) {
const seat = await this.prisma.hostingSeat.findFirst({
where: {
tenantId,
authorizedUserId: userId,
accountId: null,
bindExpiresAt: { gt: new Date() },
...activeSeatWhere(),
},
});
return { canBind: !!seat, bindExpiresAt: seat?.bindExpiresAt ?? null };
}
async assertAccount(
tenantId: string,
accountId: string,
db: Prisma.TransactionClient = this.prisma,
) {
if (!(await hostingAllowed(db, tenantId, accountId))) deny(HOSTING_UNAVAILABLE);
}
/** 原子占用先于外部副作用;不在网络请求期间持有数据库事务。 */
async reserve(tenantId: string, userId: string) {
return this.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM app.hosting_seats WHERE tenant_id=${tenantId}::uuid
AND authorized_user_id=${userId}::uuid AND account_id IS NULL FOR UPDATE`;
const seat = await tx.hostingSeat.findFirst({
where: {
tenantId,
authorizedUserId: userId,
accountId: null,
bindExpiresAt: { gt: new Date() },
...activeSeatWhere(),
},
});
if (!seat) deny('尚未获得有效的新增绑号授权,请联系管理员开通托管席位');
if (seat.reservationId) {
if (!seat.deviceId) deny('绑号请求正在处理或结果待核实,请勿重复申请;请联系管理员');
return { reservationId: seat.reservationId, deviceId: seat.deviceId };
}
const reservationId = `ai-login-${randomUUID()}`;
await tx.hostingSeat.update({
where: { id: seat.id },
data: { reservationId, reservedAt: new Date() },
});
return { reservationId, deviceId: null };
});
}
async attachDevice(tenantId: string, userId: string, reservationId: string, deviceId: string) {
const r = await this.prisma.hostingSeat.updateMany({
where: {
tenantId,
authorizedUserId: userId,
reservationId,
accountId: null,
deviceId: null,
},
data: { deviceId },
});
if (r.count !== 1) deny('绑号授权已变更,请联系管理员核实本次设备');
}
/** 与账号登记同一事务;重复回调只能确认同一个席位、同一个账号。 */
async consume(
tx: Prisma.TransactionClient,
tenantId: string,
userId: string,
deviceId: string,
accountId: string,
) {
await tx.$queryRaw`SELECT id FROM app.hosting_seats WHERE tenant_id=${tenantId}::uuid
AND authorized_user_id=${userId}::uuid AND device_id=${deviceId} FOR UPDATE`;
const seat = await tx.hostingSeat.findFirst({
where: {
tenantId,
authorizedUserId: userId,
deviceId,
bindExpiresAt: { gt: new Date() },
...activeSeatWhere(),
},
});
if (!seat || (seat.accountId && seat.accountId !== accountId))
deny('本次绑号授权已失效,请联系管理员');
await tx.hostingSeat.update({ where: { id: seat.id }, data: { accountId } });
}
async accounts(tenantId: string, ids: string[]) {
const seats = await this.prisma.hostingSeat.findMany({
where: { tenantId, accountId: { in: ids } },
});
return new Map(
seats.map((s) => [
s.accountId!,
{
active: !s.revokedAt && (!s.expiresAt || s.expiresAt.getTime() > Date.now()),
expiresAt: s.expiresAt,
},
]),
);
}
}
......@@ -39,34 +39,6 @@ export class IntegrationAccessService {
if (!user.hostScope || (await this.patients(user, [id])).has(id)) return;
throw new BizError(12812, '患者不存在或未获准访问');
}
async contactKeys(user: AuthUser): Promise<Array<{ accountId: string; vid: string }> | null> {
if (!user.hostScope || user.hostScope.wholeTenant) return null;
return memoPerRequest('host.contacts:' + user.id, async () => {
const accounts = await this.scope.accountScope(user);
const links = await this.prisma.contactLink.findMany({
where: {
tenantId: user.tenantId,
isActive: true,
...(accounts.all ? {} : { accountId: { in: accounts.accountIds } }),
},
select: { accountId: true, peerVid: true, hostPatientId: true },
});
const allowed = await this.patients(
user,
links.map((l) => l.hostPatientId),
);
const byKey = new Map<string, typeof links>();
for (const l of links) {
const key = l.accountId + '\0' + l.peerVid;
const rows = byKey.get(key) ?? [];
rows.push(l);
byKey.set(key, rows);
}
return [...byKey.values()]
.filter((rows) => rows.length && rows.every((l) => allowed.has(l.hostPatientId)))
.map((rows) => ({ accountId: rows[0]!.accountId, vid: rows[0]!.peerVid }));
});
}
async conversationIds(user: AuthUser): Promise<string[] | null> {
if (!user.hostScope || user.hostScope.wholeTenant) return null;
return memoPerRequest('host.conversations:' + user.id, async () => {
......@@ -134,9 +106,6 @@ export class IntegrationAccessService {
const accounts = await this.scope.accountScope(user);
if (!row || (!accounts.all && !accounts.accountIds.includes(row.accountId)))
throw new BizError(12806, '联系人不存在或未获准访问');
const keys = await this.contactKeys(user);
if (keys && !keys.some((k) => k.accountId === row.accountId && k.vid === row.vid))
throw new BizError(12806, '联系人不存在或未获准访问');
}
async assertContactKeys(
user: AuthUser,
......@@ -146,10 +115,13 @@ export class IntegrationAccessService {
const accounts = await this.scope.accountScope(user);
if (!accounts.all && !accounts.accountIds.includes(accountId))
throw new BizError(12806, '联系人不存在或未获准访问');
const keys = await this.contactKeys(user);
const allowed = new Set(keys?.filter((k) => k.accountId === accountId).map((k) => k.vid));
if (keys && vids.some((vid) => !allowed.has(vid)))
throw new BizError(12806, '无法核验联系人的组织范围');
const contacts = await this.prisma.wecomContact.findMany({
where: { tenantId: user.tenantId, accountId, vid: { in: [...vids] } },
select: { vid: true },
});
const allowed = new Set(contacts.map((c) => c.vid));
if (vids.some((vid) => !allowed.has(vid)))
throw new BizError(12806, '联系人不存在或未获准访问');
}
async assertTask(user: AuthUser, taskId: string): Promise<void> {
if (!user.hostScope) return;
......@@ -198,8 +170,15 @@ export class IntegrationAccessService {
select: { id: true },
});
if (self) return;
const keys = await this.contactKeys(user);
if (!keys?.some((k) => k.vid === id)) throw new BizError(12806, '资源不存在或未获准访问');
const accounts = await this.scope.accountScope(user);
const contact = await this.prisma.wecomContact.findFirst({
where: {
tenantId: user.tenantId, vid: id,
...(accounts.all ? {} : { accountId: { in: accounts.accountIds } }),
},
select: { id: true },
});
if (!contact) throw new BizError(12806, '资源不存在或未获准访问');
}
}
async patientConversations(
......@@ -312,16 +291,6 @@ export class IntegrationAccessGuard implements CanActivate {
if (needsWorkbench && !user.hostScope.modules.includes('workbench'))
throw new BizError(12811, '入口模块未开通');
req.hostCapability = cap;
if (
!user.hostScope.wholeTenant &&
[
'/contacts/requests',
'/contacts/requests/accept',
'/contacts/wecom-search',
'/contacts/add-friend',
].includes(path)
)
throw new BizError(12806, '当前组织范围不能核验未关联患者的微信身份,请使用获准的集团入口');
const conversation = /^\/conversations\/([0-9a-f-]{36})(?:\/|$)/i.exec(path);
if (conversation) await this.access.assertConversation(user, conversation[1]!);
const job = /^\/conversations\/jobs\/([0-9a-f-]{36})/i.exec(path);
......
......@@ -20,6 +20,7 @@ import { expandSubtree } from '../scope/org-tree';
export type IntegrationDb = Pick<
Prisma.TransactionClient,
| 'tenant'
| 'userRoleGrant'
| 'user'
| 'userIdentity'
| 'userOrgScope'
......@@ -259,6 +260,9 @@ export class IntegrationPolicyService {
}
async context(session: AuthSession, db: IntegrationDb = this.prisma): Promise<HostScope | null> {
if (session.source !== 'host') return null;
if (!session.tenantId || session.scope === 'platform') throw new BizError(12810, '宿主会话范围无效');
const roleGrant = await db.userRoleGrant.findUnique({where:{userId_scope:{userId:session.userId,scope:'tenant'}}});
if (!roleGrant || roleGrant.tenantId !== session.tenantId) throw new BizError(12810, '角色授权已撤销');
const ctx = session.integrationContext as unknown as HostSessionContext;
if (ctx?.version !== 2 || !ctx.appId || !ctx.launchId)
throw new BizError(12810, '旧宿主会话已结束,请重新进入');
......
......@@ -17,7 +17,7 @@ export const BASIC_WRITE = [
'channel.bind',
'appointment.task.process',
] as const;
export const CAPABILITIES = [...BASIC_READ, ...BASIC_WRITE, ...Object.values(PERMISSIONS)] as const;
export const CAPABILITIES = [...BASIC_READ, ...BASIC_WRITE, ...Object.values(PERMISSIONS).filter((p) => !p.startsWith('platform.'))] as const;
const BASE_BY_ROLE: Record<string, readonly string[]> = {
viewer: BASIC_READ,
staff: [...BASIC_READ, ...BASIC_WRITE],
......
import { setTenantRole } from '../../common/role-grants';
import { Injectable } from '@nestjs/common';
import { permissionsOf, PERMISSIONS } from '../../common/permissions';
import { Cron } from '@nestjs/schedule';
......@@ -179,7 +180,10 @@ export class IntegrationService {
if (user.tenantId !== graph.row.id || !user.isActive || user.mustChangePassword)
throw new BizError(12805, '人员已停用或尚未完成既有账号激活');
const updates: Prisma.UserUpdateInput = {};
if (user.integrationManagedRole && user.role !== role.role) updates.role = role.role;
if (user.integrationManagedRole && user.role !== role.role) {
await setTenantRole(tx, graph.row.id, user.id, role.role, 'host');
user = { ...user, role: role.role };
}
if (displayName && user.integrationManagedName && user.displayName !== displayName)
updates.displayName = displayName;
if (Object.keys(updates).length)
......@@ -226,7 +230,7 @@ export class IntegrationService {
const issued = new Date();
const session = await this.sessions.createHostInTransaction(
tx,
user,
{ ...user, tenantId: graph.row.id },
context,
requestId,
issued,
......
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '../auth/jwt.service';
import { PrismaService } from '../../prisma/prisma.service';
import { BizError } from '../../common/errors/biz-error';
import { AiApiCode } from '../../common/errors/api-codes';
import { StorageService } from './storage.service';
/**
* 出站附件的**一次性取件口** —— 只给 ECom 用。
*
* ═══ ⚠️⚠️ 为什么必须有一个免登录的口 ═══════════════════════════════════════
*
* ECom 的**所有**上传/发送接口都只收 **URL**:`[11030]/[11031]` 的 `url`、
* `[201000]` 的 `file` —— 全是「你给地址,我去取」,**没有 base64、没有 multipart**。
* 而我们的附件端点(`/api/attachments/:id`)每一次读都要 JWT + 数据范围。
* ⇒ 要把一张图发给患者,就必须给 ECom 一个**它够得着**的地址。
*
* ═══ 它和我们批评过的那件事**不是**一回事 ═══════════════════════════════════
*
* [09 §13.5] 批评供应商的是:`[201001]` 返回的腾讯云 COS 地址**无鉴权、长期有效**,
* 任何拿到 URL 的人都能下患者的照片。
*
* 这里是**签过名、几分钟就废、只绑一个附件**的能力凭据:
* · Ed25519 签名(和登录令牌同一把私钥,但 `pur: 'asset'` —— 两类令牌互相用不了)
* · 默认 10 分钟过期
* · `sub` 就是那一个 attachment id,换一个 id 签名立刻失效
*
* ⚠️⚠️ 但它**确实**是「拿到票就能取」——端点上没有"你是谁"可判。所以:
* ⛔ **票绝不能进日志**(URL 里就带着它);
* ⛔ 也不要把 TTL 调长图省事:那就一步步变成我们批评的那个东西。
*/
/** 出站附件统一使用 OSS 短效签名地址,本地和部署环境走同一条取件链路。 */
@Injectable()
export class OutboundAssetService {
private readonly publicBaseUrl: string;
private readonly ttlSeconds: number;
constructor(
private readonly jwt: JwtService,
config: ConfigService,
) {
const c = config.getOrThrow<{ publicBaseUrl: string; ttlSeconds: number }>('outboundAsset');
this.publicBaseUrl = c.publicBaseUrl;
this.ttlSeconds = c.ttlSeconds;
}
private readonly prisma: PrismaService,
private readonly storage: StorageService,
) {}
/**
* 给一个附件签一张票,拼成 ECom 能取的完整 URL。
*
* ⚠️ 没配 `AI_PUBLIC_BASE_URL` 时**抛错而不是拼一个 localhost** ——
* 拼出来的话 ECom 会去取它自己的 localhost,拿到的错误信息毫无指向性。
* 明说"没配公网地址"才让人知道去哪修。
*/
signedUrl(attachmentId: string): string {
if (!this.publicBaseUrl) {
throw new Error(
'没有配 AI_PUBLIC_BASE_URL —— ECom 只收 URL,拿不到公网地址就发不了图片/文件。' +
'生产填回调用的那个域名;本地开发没有公网地址,这个功能本地跑不通。',
);
}
const { token } = this.jwt.signAsset(attachmentId, this.ttlSeconds);
return `${this.publicBaseUrl}/api/outbound-assets/${token}`;
/** 投递时现签;⛔ 不把签名地址持久化到队列或日志中。 */
async signedUrl(tenantId: string, attachmentId: string): Promise<string> {
const attachment = await this.prisma.attachment.findFirst({
where: { id: attachmentId, tenantId },
select: { storageKey: true },
});
if (!attachment) throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '附件不存在');
const url = this.storage.signedUrl(attachment.storageKey);
if (!url) throw new Error('对象存储无法生成附件取件地址');
return url;
}
}
import { hostingAllowed, HOSTING_UNAVAILABLE } from '../hosting/hosting-seat.service';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AI_PAUSE_SCOPE_ZH, aiSilencedQuery } from '../agent/shared/ai-pause';
......@@ -10,7 +11,7 @@ export type GateVerdict =
| {
pass: false;
/** ⚠️ `account_online` **不是开关** —— 它走 hold,其余走作废。见类注释 */
gate: 'account_missing' | 'account_online' | 'ai_paused';
gate: 'account_missing' | 'account_online' | 'ai_paused' | 'hosting_expired';
reason: string;
};
......@@ -61,7 +62,7 @@ export class GateService {
/**
* 投递前过闸。
*
* @param actor `human` **一律放行**(只做账号存在性和在线检查);`agent` 过四层开关。
* @param actor `human` **一律放行**(仍校验账号存在、有效托管席位和在线状态);`agent` 过四层开关。
*/
async check(opts: {
tenantId: string;
......@@ -86,6 +87,9 @@ export class GateService {
* 让它去撞 ECom 则是 retry,8 次退避后进死信 —— 患者的回复就真没了。
* ② 掉线期间反复调 ECom 是**风控信号**。号刚掉线还猛敲接口是最坏的姿势。
*/
if (!await hostingAllowed(this.prisma, opts.tenantId, opts.accountId)) {
return { pass: false, gate: 'hosting_expired', reason: HOSTING_UNAVAILABLE };
}
if (account.status !== 'online') {
return {
pass: false,
......
......@@ -30,16 +30,13 @@ interface SendResult {
*
* `[11030]` 的 `url`(「传该参数则会自动上传并发送,无须自己上传」)和
* `[11031]` 的 `url`(required)—— **它自己去取**,没有 base64 也没有 multipart。
* ⇒ 投递前现签一张**一次性取件票**(`OutboundAssetService.signedUrl`),
* 10 分钟就废。
* ⇒ 投递前现签一张**OSS 短效签名地址**(`OutboundAssetService.signedUrl`),
* 有效期复用 OSS 配置(默认 10 分钟)。
*
* ⚠️ **票在投递那一刻才签,不放进 payload**:任务可能在队列里躺很久
* (闸门关着时会 hold 好几天),payload 里存一张过期的票,比不存更糟 ——
* 和 `deviceId` 不放 payload 是同一条理由。
*
* ⚠️⚠️ **本地开发跑不通**:没有公网地址 ECom 就取不到那个 URL。
* 这是环境限制不是 bug ——`signedUrl()` 在没配 `AI_PUBLIC_BASE_URL` 时会明说。
*
* ═══ 和文本共用的那些 ═══════════════════════════════════════════════════════
*
* 闸门、`deviceId` 现查、双层错误码、`self_recorded` 记账、实时广播 —— 全都一样。
......@@ -103,11 +100,11 @@ export class OutboundMediaHandler implements OutboxHandler {
let url: string;
try {
url = this.assets.signedUrl(p.attachmentId);
url = await this.assets.signedUrl(task.tenantId, p.attachmentId);
} catch (e) {
// ⚠️ 没配公网地址 —— 这是**配置问题不是临时故障**,重试八次也一样。
// ⇒ dead + 一条说得清去哪修的错。
return { ok: false, kind: 'dead', error: e instanceof Error ? e.message : String(e) };
// 附件不存在是永久错误;数据库等临时故障交给 worker 重试。
if (e instanceof BizError) return { ok: false, kind: 'dead', error: e.msg };
throw e;
}
const isImage = p.kind === 'image';
......
import { hostingAllowed, HOSTING_UNAVAILABLE } from '../hosting/hosting-seat.service';
import { splitWxFaces } from '@pac/ecom-client';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
......@@ -90,6 +91,7 @@ export class OutboundNotifyHandler implements OutboxHandler {
select: { status: true, deviceId: true, purpose: true, offlineReason: true },
});
if (!acct) return { ok: false, kind: 'dead', error: '找不到提醒号' };
if (!await hostingAllowed(this.prisma, task.tenantId, p.accountId)) return { ok: true, status: 'canceled', reason: HOSTING_UNAVAILABLE };
/**
* ⚠️ 这一条是**结构性**的:提醒号被改回 `patient` 之后还继续用它发提醒,
* 就是拿一个接患者的号去打扰同事 —— 而那个号的昵称患者认得。
......
......@@ -9,7 +9,7 @@ import { MediaModule } from '../media/media.module';
import { OutboundService } from './outbound.service';
@Module({
// ⚠️ MediaModule 是为 OutboundAssetController.signedUrl 来的
// ⚠️ MediaModule 是为 OutboundAssetService.signedUrl 来的
// (出站附件要现签一张 ECom 够得着的一次性 URL)
imports: [ChannelModule, MediaModule],
providers: [
......
import { hostingAllowed, HOSTING_UNAVAILABLE } from '../hosting/hosting-seat.service';
import { currentTenant } from '../../common/tenant-context';
import { Injectable } from '@nestjs/common';
import { contentListWeight, MAX_CONTENT_WEIGHT } from '@pac/ecom-client';
......@@ -153,6 +154,9 @@ export class OutboundService {
});
if (!conv)
throw new BizError(AiApiCode.CONVERSATION_NOT_FOUND, '找不到这个会话,请刷新页面后再试');
if (input.actor === 'human' && !await hostingAllowed(tx, input.tenantId, conv.account.id)) {
throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, HOSTING_UNAVAILABLE);
}
// 群聊发 roomId,单聊发对方 vid
const receiver = conv.kind === 'group' ? conv.roomId : conv.peerVid;
......@@ -233,6 +237,9 @@ export class OutboundService {
});
if (!conv)
throw new BizError(AiApiCode.CONVERSATION_NOT_FOUND, '找不到这个会话,请刷新页面后再试');
if (input.actor === 'human' && !await hostingAllowed(tx, input.tenantId, conv.account.id)) {
throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, HOSTING_UNAVAILABLE);
}
const receiver = conv.kind === 'group' ? conv.roomId : conv.peerVid;
if (!receiver) {
throw new BizError(AiApiCode.CONVERSATION_NOT_FOUND, '这个会话暂时发不出消息,请刷新页面后再试');
......@@ -295,6 +302,9 @@ export class OutboundService {
});
if (!conv)
throw new BizError(AiApiCode.CONVERSATION_NOT_FOUND, '找不到这个会话,请刷新页面后再试');
if (input.actor === 'human' && !await hostingAllowed(tx, input.tenantId, conv.account.id)) {
throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, HOSTING_UNAVAILABLE);
}
const receiver = conv.kind === 'group' ? conv.roomId : conv.peerVid;
if (!receiver) {
throw new BizError(AiApiCode.CONVERSATION_NOT_FOUND, '这个会话暂时发不出消息,请刷新页面后再试');
......
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { PasswordService, toE164 } from '../auth/password.service';
import { z } from 'zod';
/** 平台人员授权仅由运维 CLI 管理,客户与宿主接口均不能调用。 */
@Injectable()
export class PlatformAdminService {
constructor(
private readonly db: PrismaService,
private readonly passwords: PasswordService,
) {}
async execute(input: {
userId?: string;
phone?: string;
name?: string;
role?: string;
revoke?: boolean;
reason: string;
}) {
const reason = z.string().trim().min(1).max(500).parse(input.reason);
const role = z
.enum(['platform_admin', 'platform_viewer'])
.parse(input.role ?? 'platform_admin');
const id = input.userId ? z.uuid().parse(input.userId) : null;
const phone = input.phone ? toE164(input.phone) : null;
if (!id && (!phone || !input.name?.trim()))
throw new Error('新账号需要有效手机号和姓名;已有账号使用 --user');
if (id && (input.phone || input.name)) throw new Error('--user 与 --phone/--name 不能同时提供');
if (input.revoke && !id) throw new Error('撤权需要 --user');
const temporary = id ? null : this.passwords.generateTemporary();
const passwordHash = temporary ? await this.passwords.hash(temporary) : null;
const result = await this.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(7610031)`;
const user = id
? await tx.user.findUniqueOrThrow({ where: { id } })
: await tx.user.create({
data: {
phone: phone!,
displayName: input.name!.trim(),
role: 'viewer',
passwordHash,
passwordSetAt: new Date(),
mustChangePassword: true,
},
});
await tx.$queryRaw`SELECT id FROM app.users WHERE id=${user.id}::uuid FOR UPDATE`;
if (!user.isActive) throw new Error('账号已停用');
if (!input.revoke && !user.passwordHash)
throw new Error('该账号尚未设置密码,请先通过 ai:user 重置密码后再授予平台角色');
const before = await tx.userRoleGrant.findUnique({
where: { userId_scope: { userId: user.id, scope: 'platform' } },
});
if (before?.role === 'platform_admin' && (input.revoke || role !== 'platform_admin')) {
const count = await tx.userRoleGrant.count({
where: { scope: 'platform', role: 'platform_admin', user: { isActive: true } },
});
if (count <= 1) throw new Error('不能撤销最后一位平台管理员');
}
if (input.revoke)
await tx.userRoleGrant.deleteMany({ where: { userId: user.id, scope: 'platform' } });
else
await tx.userRoleGrant.upsert({
where: { userId_scope: { userId: user.id, scope: 'platform' } },
create: { userId: user.id, scope: 'platform', role, source: 'manual' },
update: { role, source: 'manual' },
});
await tx.authSession.updateMany({
where: { userId: user.id, scope: 'platform', revokedAt: null },
data: { revokedAt: new Date() },
});
// 旧客户激活票不能在提权后变成平台账号的密码重置凭据。
await tx.userActivation.updateMany({
where: { userId: user.id, usedAt: null },
data: { usedAt: new Date() },
});
await tx.auditLog.create({
data: {
scope: 'platform',
tenantId: user.tenantId,
action: input.revoke ? 'platform.user.revoke' : 'platform.user.grant',
objectType: 'user',
objectId: user.id,
actorType: 'system',
actorRef: 'cli:platform',
result: 'ok',
changes: { reason, before: before?.role ?? null, after: input.revoke ? null : role },
},
});
return { id: user.id, displayName: user.displayName, role: input.revoke ? null : role };
});
return { ...result, ...(temporary ? { temporaryPassword: temporary } : {}) };
}
}
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import { z } from 'zod';
import { ZodValidationException } from 'nestjs-zod';
function input<T extends z.ZodType>(schema: T, value: unknown): z.output<T> {
const parsed = schema.safeParse(value);
if (!parsed.success) throw new ZodValidationException(parsed.error);
return parsed.data;
}
import { AuthScope } from '../../common/decorators/auth-scope.decorator';
import { AllowTempPassword } from '../../common/decorators/temp-password.decorator';
import { AllowViewerWrite } from '../../common/decorators/viewer-write.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { PERMISSIONS } from '../../common/permissions';
import type { PlatformUser } from '../../common/guards/jwt-auth.guard';
import { LoginThrottleGuard, ThrottleCost } from '../auth/login-throttle.guard';
import { PasswordLoginService } from '../auth/password-login.service';
import { SessionService } from '../auth/session.service';
import { PlatformService } from './platform.service';
@Controller('platform')
@AuthScope('platform')
export class PlatformController {
constructor(
private readonly platform: PlatformService,
private readonly passwords: PasswordLoginService,
private readonly sessions: SessionService,
) {}
@Get('me')
@AllowTempPassword()
me(@Req() req: { platformUser: PlatformUser }) {
return req.platformUser;
}
@Post('password')
@AllowTempPassword()
@AllowViewerWrite()
@UseGuards(LoginThrottleGuard)
@ThrottleCost(2)
async password(@Req() req: { platformUser: PlatformUser }, @Body() body: unknown) {
const b = input(
z.object({ oldPassword: z.string().max(128), newPassword: z.string().max(128) }),
body,
);
await this.passwords.changePassword(
req.platformUser.id,
b.oldPassword,
b.newPassword,
'platform',
);
await this.sessions.revokeAll(req.platformUser.id);
return { ok: true };
}
@Get('tenants')
@RequirePermission(PERMISSIONS.PLATFORM_VIEW)
tenants() {
return this.platform.tenants();
}
@Get('tenants/:id')
@RequirePermission(PERMISSIONS.PLATFORM_VIEW)
detail(@Param('id') id: string) {
return this.platform.detail(input(z.uuid(), id));
}
@Post('tenants/:id/hosting/:command')
@RequirePermission(PERMISSIONS.PLATFORM_HOSTING_MANAGE)
hosting(
@Param('id') id: string,
@Param('command') command: string,
@Body() body: unknown,
@Req() req: { platformUser: PlatformUser },
) {
const b = input(
z
.object({
seat: z.uuid().optional(),
user: z.uuid().optional(),
expires: z.iso.datetime({ offset: true }).optional(),
permanent: z.boolean().optional(),
bindHours: z.number().positive().max(168).optional(),
resetBinding: z.boolean().optional(),
reason: z.string().trim().min(1).max(500),
})
.strict(),
body,
);
return this.platform.hosting(
req.platformUser,
input(z.uuid(), id),
input(z.enum(['grant', 'renew', 'authorize', 'revoke']), command),
b,
);
}
@Get('audit')
@RequirePermission(PERMISSIONS.PLATFORM_AUDIT_VIEW)
audit() {
return this.platform.audit();
}
}
import { PlatformAdminService } from './platform-admin.service';
import { Module } from '@nestjs/common';
import { PlatformController } from './platform.controller';
import { PlatformService } from './platform.service';
import { HostingAdminService } from '../hosting/hosting-admin.service';
@Module({
controllers: [PlatformController],
providers: [PlatformAdminService, PlatformService, HostingAdminService],
exports: [HostingAdminService],
})
export class PlatformModule {}
import {
Injectable,
BadRequestException,
NotFoundException,
InternalServerErrorException,
} from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { PrismaService } from '../../prisma/prisma.service';
import { HostingAdminService } from '../hosting/hosting-admin.service';
import type { PlatformUser } from '../../common/guards/jwt-auth.guard';
import { maskPhone } from '../../common/mask';
@Injectable()
export class PlatformService {
constructor(
private readonly db: PrismaService,
private readonly seats: HostingAdminService,
) {}
async tenants() {
const tenants = await this.db.tenant.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
status: true,
partnerCode: true,
partnerTenantId: true,
createdAt: true,
},
});
const seats = await this.db.hostingSeat.findMany({
where: { tenantId: { in: tenants.map((t) => t.id) } },
select: { tenantId: true, accountId: true, expiresAt: true, revokedAt: true },
});
return tenants.map((t) => {
const rows = seats.filter((s) => s.tenantId === t.id);
const active = rows.filter((s) => !s.revokedAt && (!s.expiresAt || s.expiresAt > new Date()));
return {
...t,
seats: {
total: rows.length,
active: active.length,
occupied: rows.filter((s) => s.accountId).length,
available: active.filter((s) => !s.accountId).length,
},
};
});
}
async detail(tenantId: string) {
const tenant = await this.db.tenant.findUnique({
where: { id: tenantId },
select: {
id: true,
name: true,
status: true,
partnerCode: true,
partnerTenantId: true,
partnerOrgRootRef: true,
integrationEnabled: true,
integrationAppId: true,
},
});
if (!tenant) throw new NotFoundException('找不到这个客户');
const users = await this.db.user.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
select: { id: true, displayName: true, phone: true, role: true, isActive: true },
});
const accounts = await this.db.wecomAccount.findMany({
where: { tenantId },
select: { id: true, nickname: true, userId: true, status: true, releasedAt: true },
});
const seats = await this.db.hostingSeat.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
});
return {
tenant,
users: users.map((u) => ({ ...u, phone: u.phone ? maskPhone(u.phone) : null })),
accounts,
seats,
};
}
async hosting(
actor: PlatformUser,
tenant: string,
command: string,
b: {
seat?: string;
user?: string;
expires?: string;
permanent?: boolean;
bindHours?: number;
resetBinding?: boolean;
reason: string;
},
) {
const input: Record<string, string> = { tenant, reason: b.reason };
for (const key of ['seat', 'user', 'expires'] as const) if (b[key]) input[key] = b[key]!;
if (b.permanent) input.permanent = 'true';
if (b.bindHours) input['bind-hours'] = String(b.bindHours);
if (b.resetBinding) input['reset-binding'] = 'true';
try {
return await this.seats.execute(command, input, { userId: actor.id, ref: 'platform' });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2025') throw new NotFoundException('找不到对应的客户、员工或席位');
throw new InternalServerErrorException('操作未完成,请稍后重试');
}
if (e instanceof Error && !e.message.includes('prisma'))
throw new BadRequestException(e.message);
throw e;
}
}
async audit() {
const rows = await this.db.auditLog.findMany({
where: { scope: 'platform' },
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
take: 100,
select: {
id: true,
tenantId: true,
occurredAt: true,
action: true,
objectType: true,
objectId: true,
actorUserId: true,
actorRef: true,
result: true,
changes: true,
},
});
const [users, tenants] = await Promise.all([
this.db.user.findMany({
where: { id: { in: rows.flatMap((r) => (r.actorUserId ? [r.actorUserId] : [])) } },
select: { id: true, displayName: true },
}),
this.db.tenant.findMany({
where: { id: { in: rows.flatMap((r) => (r.tenantId ? [r.tenantId] : [])) } },
select: { id: true, name: true },
}),
]);
return rows.map((r) => ({
...r,
actorName: users.find((u) => u.id === r.actorUserId)?.displayName ?? r.actorRef ?? '系统',
tenantName: tenants.find((t) => t.id === r.tenantId)?.name ?? null,
}));
}
}
......@@ -70,9 +70,9 @@ export class HostAuthorizeController {
userId=identity.userId;
}
const found = await this.prisma.user.findUnique({where:{id:userId},include:{tenant:{select:{status:true}}}});
if (!found?.isActive || found.tenant.status !== 'active' || found.mustChangePassword || !permissionsOf(found.role).includes(req.action as any))
if (!found?.tenantId || !found.isActive || !found.tenant || found.tenant.status !== 'active' || found.mustChangePassword || !permissionsOf(found.role).includes(req.action as any))
throw new ForbiddenException('客户或操作人没有该预约动作权限');
user=found;
user={...found,tenantId:found.tenantId,tenant:found.tenant};
if (req.principal.kind === 'ai' && user.tenantId !== req.principal.tenantId) throw new ForbiddenException();
}
if (req.principal.kind === 'ai' && req.sourceRef) {
......
......@@ -242,7 +242,7 @@ export class ScopeService {
select: { orgUnitId: true, includeSubtree: true },
}),
]);
return canManage(op, reach, owner ? { ...owner, anchors } : null).ok;
return canManage(op, reach, owner?.tenantId ? { ...owner, tenantId: owner.tenantId, anchors } : null).ok;
}
/**
......
import { IntegrationPolicyService } from '../integration/integration-policy.service';
import { IntegrationAccessService } from '../integration/integration-access.service';
import { Injectable, Logger, Optional } from '@nestjs/common';
import { friendshipOf, type Friendship } from '../channel/friendship';
import { PrismaService } from '../../prisma/prisma.service';
......@@ -54,7 +53,6 @@ export class ContactService {
private readonly friendAdd: FriendAddService,
private readonly takeover: TakeoverService,
private readonly realtime: RealtimeService,
@Optional() private readonly hostAccess?: IntegrationAccessService,
@Optional() private readonly integration?: IntegrationPolicyService,
) {}
......@@ -153,10 +151,6 @@ export class ContactService {
}
// 已绑的那批 peer_vid:三档里有两档要它,一次查清
if (user.hostScope) {
const keys = await this.hostAccess!.contactKeys(user);
if (keys) base.AND = [...((base.AND as unknown[]) ?? []), { OR: keys.length ? keys.map((k) => ({ accountId: k.accountId, vid: k.vid })) : [{ id: { in: [] } }] }];
}
const linked = await this.prisma.contactLink.findMany({
where: { tenantId: user.tenantId, ...byAccount, isActive: true },
select: { accountId: true, peerVid: true },
......
import { Injectable, Logger, Optional } from '@nestjs/common';
import { IntegrationAccessService } from '../integration/integration-access.service';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { BizError } from '../../common/errors/biz-error';
import { AiApiCode } from '../../common/errors/api-codes';
......@@ -95,7 +94,6 @@ export class GroupService {
private readonly pac: PacService,
private readonly links: ContactLinkService,
private readonly rooms: RoomCreateService,
@Optional() private readonly hostAccess?: IntegrationAccessService,
) {}
/**
......@@ -114,12 +112,10 @@ export class GroupService {
): Promise<GroupCandidates> {
const { account, accounts } = await this.pickAccount(user, accountId);
const { clinicId } = await this.scope.currentClinic(user, clinic);
const keys = user.hostScope ? await this.hostAccess!.contactKeys(user) : null;
// ── 患者组 ──────────────────────────────────────────────
const linked = await this.prisma.contactLink.findMany({
where: { tenantId: user.tenantId, accountId: account.id, isActive: true,
...(keys ? { peerVid: { in: keys.filter((k) => k.accountId === account.id).map((k) => k.vid) } } : {}) },
where: { tenantId: user.tenantId, accountId: account.id, isActive: true },
select: { peerVid: true },
take: MAX_CANDIDATES + 1,
});
......
import { setTenantRole } from '../../common/role-grants';
import { IntegrationPolicyService } from '../integration/integration-policy.service';
import { Injectable, Logger, Optional } from '@nestjs/common';
import { randomBytes } from 'node:crypto';
......@@ -355,7 +356,7 @@ export class UserAdminService {
if (dryRun) return { ...base, applied: false };
// ⚠️ 只改 role:⛔ 动 data_scope(能看多远是另一件事),⛔ 动 token_version(守卫每请求读库,立刻生效)
await runTx(this.prisma, async (tx) => {
await tx.user.updateMany({ where: { tenantId: op.tenantId, id }, data: { role: want, integrationManagedRole: false } });
await setTenantRole(tx, op.tenantId, id, want, 'manual', op.id);
await audit(tx, op, 'user.role', 'user', id, { from: target.role, to: want });
});
return { ...base, applied: true };
......@@ -376,6 +377,8 @@ export class UserAdminService {
const expiresAt = new Date(Date.now() + ACTIVATION_TTL_MS);
try {
await runTx(this.prisma, async (tx) => {
await tx.$queryRaw`SELECT id FROM app.users WHERE id=${id}::uuid FOR UPDATE`;
if (await tx.userRoleGrant.findUnique({where:{userId_scope:{userId:id,scope:'platform'}}})) throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '平台账号由平台管理');
// ⚠️ 先作废旧票再建新票:uq_ua_live 只许一张没用过的票
const revoked = await tx.userActivation.updateMany({
where: { tenantId: op.tenantId, userId: id, usedAt: null },
......@@ -413,7 +416,7 @@ export class UserAdminService {
const anchors = u
? await db.userOrgScope.findMany({ where: { tenantId: op.tenantId, userId: id }, select: { orgUnitId: true, includeSubtree: true } })
: [];
const v = canManage(op, reach, u ? { ...u, anchors } : null);
const v = canManage(op, reach, u?.tenantId ? { ...u, tenantId: u.tenantId, anchors } : null);
/**
* ⭐⭐ **改自己的「所在诊所 / 岗位」是允许的**(2026-09-14 起)。
*
......@@ -428,10 +431,11 @@ export class UserAdminService {
if (what === 'scopes') throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '不能在这里改你自己的授权,请找另一位管理员');
throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '不能在这里改你自己');
}
if ((!v.ok && !selfProfile) || !u) throw new BizError(AiApiCode.USER_NOT_FOUND, '找不到这个人,或者他不在你的管理范围里');
if ((!v.ok && !selfProfile) || !u?.tenantId) throw new BizError(AiApiCode.USER_NOT_FOUND, '找不到这个人,或者他不在你的管理范围里');
if (what === 'activation' && await db.userRoleGrant.findUnique({where:{userId_scope:{userId:id,scope:'platform'}}})) throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '平台账号由平台管理');
if (!u.isActive) throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '这个账号已停用');
const { passwordHash, ...rest } = u;
return { ...rest, activated: !!passwordHash || !!(this.integration && (await this.integration.hostLoginUsers(op.tenantId, [u.id], db)).has(u.id)), anchors };
return { ...rest, tenantId: u.tenantId, activated: !!passwordHash || !!(this.integration && (await this.integration.hostLoginUsers(op.tenantId, [u.id], db)).has(u.id)), anchors };
}
/**
......
......@@ -52,6 +52,9 @@ const SCOPED_MODELS = new Set<string>(
.map((m) => m.name),
);
// 可空的统一用户/审计/会话/授权仍必须按客户请求隔离。
for (const model of ['User', 'AuditLog', 'AuthSession', 'UserRoleGrant']) SCOPED_MODELS.add(model);
const READ_OPS = new Set<string>([
'findMany',
'findFirst',
......@@ -125,6 +128,10 @@ export const tenantGuardExtension = Prisma.defineExtension({
}
}
if (ctx && model === 'AuditLog' && READ_OPS.has(operation)) {
a.where = { AND: [a.where ?? {}, { scope: 'tenant' }] };
}
return query(args);
},
},
......
......@@ -449,7 +449,7 @@ describe('设备号', () => {
const mine = stripTsComments(
readFileSync(join(__dirname, '..', '../ai-web/src/components/my-accounts-sheet.tsx'), 'utf8'),
);
expect(mine).toMatch(/const rehostable = !!a\.releasedAt/);
expect(mine).toMatch(/const rehostable = a\.hosting\?\.active !== false && !!a\.releasedAt/);
const i = mine.indexOf('rehostable ? (');
expect(i).toBeGreaterThan(0);
// ⚠️ 切窗要够宽 —— 后来给按钮加了 title(说明它沿用什么),块变长了
......@@ -505,7 +505,7 @@ describe('区划', () => {
expect(panel).toMatch(/沿用原来的设备和区划/);
expect(panel).toMatch(/\{region\}/);
expect(panel).toMatch(/换到别的省/);
expect(panel).toMatch(/绑定新的企业微信号/);
expect(panel).toMatch(/联系管理员处理/);
// ⭐ 区划显示的是**中文名**(Zhejiang → 浙江)⇒ 调用方必须先过映射
expect(m).toMatch(/regionName\[a\.region\] \?\? a\.region/);
});
......
......@@ -37,6 +37,7 @@ function fixture() {
};
let row: AuthSession | null = null;
const delegates = {
userRoleGrant: {findUnique: jest.fn(async()=>({scope:'tenant',tenantId:user.tenantId,role:user.role}))},
user: { findUnique: jest.fn(async () => user), findUniqueOrThrow: jest.fn(async () => user) },
authSession: {
create: jest.fn(async ({ data }) => {
......@@ -186,7 +187,7 @@ describe('Bearer 守卫与资源票据', () => {
const tokens = await f.sessions.create(f.user.id);
const flags = new Set<string>();
const reflector = {
getAllAndOverride: (key: string) => flags.has(key),
getAllAndOverride: (key: string) => flags.has(key) ? true : undefined,
} as unknown as Reflector;
const guard = new JwtAuthGuard(reflector, f.jwt, f.db, f.sessions);
const invoke = (
......
import { ContactService } from '../src/modules/workbench/contact.service';
import { IntegrationAccessGuard, IntegrationAccessService } from '../src/modules/integration/integration-access.service';
import type { AuthUser } from '../src/common/guards/jwt-auth.guard';
import type { HostScope } from '../src/modules/integration/integration.schema';
const native = { id: 'u', tenantId: 't', dataScope: 'scoped', role: 'staff' } as AuthUser;
const embedded: AuthUser = { ...native, hostScope: {
wholeTenant: false, orgUnitIds: ['clinic'], capabilities: ['contacts.read', 'contacts.link'], modules: ['workbench'],
} as HostScope };
function fixture() {
const rows = [
{ id: 'external', vid: 'v1', kind: 'external', accountId: 'own', tenantId: 't' },
{ id: 'internal', vid: 'v2', kind: 'internal', accountId: 'own', tenantId: 't' },
{ id: 'colleague', vid: 'v3', kind: 'external', accountId: 'other', tenantId: 't' },
{ id: 'foreign', vid: 'v4', kind: 'external', accountId: 'own', tenantId: 'other-tenant' },
].map(c => ({ ...c, nickname: c.id, remarkPhones: [], relationType: 9 }));
const matches = (row: Record<string, unknown>, where: Record<string, any>): boolean =>
Object.entries(where).every(([key, value]) => {
if (key === 'AND') return value.every((w: Record<string, any>) => matches(row, w));
if (key === 'OR') return value.some((w: Record<string, any>) => matches(row, w));
if (key === 'NOT') return !matches(row, value);
return value && typeof value === 'object' && 'in' in value ? value.in.includes(row[key]) : row[key] === value;
});
const db = {
wecomContact: {
findMany: jest.fn(async ({ where }: any) => rows.filter(c => matches(c, where))),
count: async ({ where }: any) => rows.filter(c => matches(c, where)).length,
findFirst: async ({ where }: any) => rows.find(c => matches(c, where)) ?? null,
},
wecomAccount: { findMany: async () => [{ id: 'own', status: 'online', nickname: '我的号', createdAt: new Date(0) }] },
contactLink: { findMany: async () => [] },
conversation: { findMany: async () => [] },
userIdentity: { findMany: async () => [] },
};
const scope = { accountScope: async () => ({ all: false, accountIds: ['own'] }), currentClinic: async () => ({ clinicId: 'clinic' }) };
const pac = { getPatients: jest.fn(async () => new Map()) };
const contacts = new ContactService(db as never, scope as never, pac as never, {} as never, {} as never, {} as never,
{ contactManagedMany: async () => new Map() } as never, {} as never);
const access = new IntegrationAccessService(db as never, pac as never, scope as never);
return { contacts, access, db };
}
it.each(['pending', 'bound', 'internal'] as const)('原生和宿主登录的 %s 通讯录使用同一范围和计数', async bucket => {
const { contacts } = fixture();
const a = await contacts.list(native, { bucket }), b = await contacts.list(embedded, { bucket });
expect(b).toEqual(a);
expect(b.counts).toEqual({ pending: 1, bound: 0, internal: 1 });
expect(b.items.map(i => i.id)).toEqual(bucket === 'pending' ? ['external'] : bucket === 'internal' ? ['internal'] : []);
});
it('宿主可以访问未绑定联系人及同事;不能访问其他账号或其他租户', async () => {
const { access } = fixture();
await expect(access.assertContact(embedded, 'external')).resolves.toBeUndefined();
await expect(access.assertContact(embedded, 'internal')).resolves.toBeUndefined();
for (const id of ['colleague', 'foreign', 'missing'])
await expect(access.assertContact(embedded, id)).rejects.toMatchObject({ code: 12806 });
await expect(access.assertContactKeys(embedded, 'own', ['v1', 'v2'])).resolves.toBeUndefined();
await expect(access.assertContactKeys(embedded, 'other', ['v3'])).rejects.toMatchObject({ code: 12806 });
await expect(access.assertContactKeys(embedded, 'own', ['v4'])).rejects.toMatchObject({ code: 12806 });
});
it('未绑定联系人的头像可见,其他账号头像不可见', async () => {
const { access, db } = fixture();
Object.assign(db.wecomAccount, { findFirst: async () => null });
await expect(access.assertMedia(embedded, '/avatars/v1')).resolves.toBeUndefined();
await expect(access.assertMedia(embedded, '/avatars/v3')).rejects.toMatchObject({ code: 12806 });
});
it('限定诊所入口可使用通讯录动作,但绑定患者仍校验患者权限', async () => {
const { access } = fixture();
const guard = new IntegrationAccessGuard(access);
const run = (path: string, method: string, body = {}) => guard.canActivate({ switchToHttp: () => ({
getRequest: () => ({ path: '/api' + path, method, body, user: embedded }),
}) } as never);
await expect(run('/contacts/requests', 'GET')).resolves.toBe(true);
await expect(run('/contacts/wecom-search', 'GET')).resolves.toBe(true);
const assertContact = jest.spyOn(access, 'assertContact').mockResolvedValue();
const assertPatient = jest.spyOn(access, 'assertPatient').mockRejectedValue(Object.assign(new Error('denied'), { code: 12812 }));
await expect(run('/contacts/11111111-1111-4111-8111-111111111111/patient', 'POST', { patientId: 'outside' })).rejects.toMatchObject({ code: 12812 });
expect(assertContact).toHaveBeenCalled();
expect(assertPatient).toHaveBeenCalledWith(embedded, 'outside');
});
......@@ -46,6 +46,7 @@ describe('① 群里 @ 了托管的哪个号,就在那个号那一行答', ()
return [];
},
conversation: { findUnique: async () => ({ accountId: 'a' }) },
hostingSeat: { findFirst: async () => ({ id: 'seat' }) },
} as never,
{ publish: () => undefined } as never,
);
......
import { randomUUID } from 'node:crypto';
import { PrismaClient } from '../generated/prisma';
import { HostingSeatService, hostingAllowed } from '../src/modules/hosting/hosting-seat.service';
// 显式启用;只用现有本地库,不建临时数据库,不调用供应商。
const run = process.env.AI_HOSTING_DB_TEST === '1' ? describe : describe.skip;
run('托管席位数据库约束及并发', () => {
let db: PrismaClient, service: HostingSeatService;
const tenantId = randomUUID(),
otherTenant = randomUUID(),
userId = randomUUID(),
accountId = randomUUID();
beforeAll(async () => {
if (!['localhost', '127.0.0.1'].includes(new URL(process.env.AI_DATABASE_URL!).hostname))
throw new Error('仅限本地');
db = new PrismaClient();
service = new HostingSeatService(db as never);
for (const id of [tenantId, otherTenant])
await db.tenant.create({
data: { id, name: '席位回归测试', partnerCode: 'hosting-test', partnerTenantId: id },
});
await db.user.create({ data: { id: userId, tenantId, displayName: '席位测试员工' } });
await db.wecomAccount.create({
data: {
id: accountId,
tenantId,
userId,
vid: randomUUID(),
deviceId: 'hosting-test',
region: 'Zhejiang',
},
});
});
afterEach(async () => {
await db.hostingSeat.deleteMany({ where: { tenantId: { in: [tenantId, otherTenant] } } });
});
afterAll(async () => {
if (!db) return;
await db.wecomAccount.deleteMany({ where: { tenantId } });
await db.user.deleteMany({ where: { tenantId } });
await db.tenant.deleteMany({ where: { id: { in: [tenantId, otherTenant] } } });
await db.$disconnect();
});
const grant = () =>
db.hostingSeat.create({
data: {
tenantId,
authorizedUserId: userId,
bindExpiresAt: new Date(Date.now() + 60000),
expiresAt: new Date(Date.now() + 3600000),
},
});
it('无授权拒绝;并发只有一个请求能取得新建设备资格', async () => {
await expect(service.reserve(tenantId, userId)).rejects.toThrow();
await grant();
const rs = await Promise.allSettled([
service.reserve(tenantId, userId),
service.reserve(tenantId, userId),
]);
expect(rs.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
const r = rs.find((r) => r.status === 'fulfilled') as PromiseFulfilledResult<{
reservationId: string;
}>;
await service.attachDevice(tenantId, userId, r.value.reservationId, 'test-device');
expect((await service.reserve(tenantId, userId)).deviceId).toBe('test-device');
});
it('授权期限、席位期限、租户和数据库复合外键都生效', async () => {
const s = await grant();
await expect(service.reserve(otherTenant, userId)).rejects.toThrow();
await db.hostingSeat.update({ where: { id: s.id }, data: { bindExpiresAt: new Date(0) } });
await expect(service.reserve(tenantId, userId)).rejects.toThrow();
await db.hostingSeat.update({
where: { id: s.id },
data: { bindExpiresAt: new Date(Date.now() + 60000), expiresAt: new Date(0) },
});
await expect(service.reserve(tenantId, userId)).rejects.toThrow();
await expect(
db.hostingSeat.create({ data: { tenantId: otherTenant, accountId } }),
).rejects.toThrow();
});
it('领取幂等;一个席位不能领取多个号;事务回滚不会消费授权', async () => {
await grant();
const r = await service.reserve(tenantId, userId);
await service.attachDevice(tenantId, userId, r.reservationId, 'test-device');
await expect(
db.$transaction(async (tx) => {
await service.consume(tx, tenantId, userId, 'test-device', accountId);
throw new Error('rollback');
}),
).rejects.toThrow('rollback');
expect(await hostingAllowed(db, tenantId, accountId)).toBe(false);
for (let i = 0; i < 2; i++)
await db.$transaction((tx) =>
service.consume(tx, tenantId, userId, 'test-device', accountId),
);
expect(await hostingAllowed(db, tenantId, accountId)).toBe(true);
await expect(
db.$transaction((tx) => service.consume(tx, tenantId, userId, 'test-device', randomUUID())),
).rejects.toThrow();
await expect(service.reserve(tenantId, userId)).rejects.toThrow();
});
it('永久、到期、续期、撤销;下线转交不释放席位', async () => {
const s = await db.hostingSeat.create({ data: { tenantId, accountId } });
expect(await hostingAllowed(db, tenantId, accountId)).toBe(true);
await db.wecomAccount.update({
where: { id: accountId },
data: { status: 'offline', userId: null },
});
expect((await db.hostingSeat.findUniqueOrThrow({ where: { id: s.id } })).accountId).toBe(
accountId,
);
await db.hostingSeat.update({ where: { id: s.id }, data: { expiresAt: new Date(0) } });
await expect(service.assertAccount(tenantId, accountId)).rejects.toThrow();
await db.hostingSeat.update({
where: { id: s.id },
data: { expiresAt: new Date(Date.now() + 60000) },
});
await expect(service.assertAccount(tenantId, accountId)).resolves.toBeUndefined();
await db.hostingSeat.update({ where: { id: s.id }, data: { revokedAt: new Date() } });
expect(await hostingAllowed(db, tenantId, accountId)).toBe(false);
});
});
import { QrLoginService } from '../src/modules/auth/qr-login.service';
import { GateService } from '../src/modules/outbound/gate.service';
import { EcomService } from '../src/modules/channel/ecom.service';
jest.mock('../src/modules/channel/ingress-pull.client', () => ({ localLoginRoute: jest.fn() }));
describe('席位校验先于供应商副作用', () => {
const user = { id: 'user', tenantId: 'tenant' } as never;
function build(reuse = false) {
const db = {
tenant: {
findUniqueOrThrow: jest.fn().mockResolvedValue({ id: 'tenant', status: 'active' }),
},
wecomAccount: {
findMany: jest
.fn()
.mockResolvedValue(
reuse ? [{ id: 'account', vid: 'vid', deviceId: 'old', region: 'Zhejiang' }] : [],
),
},
auditLog: { create: jest.fn() },
};
const ecom = { platformCallRaw: jest.fn().mockResolvedValue({ deviceId: 'new' }) };
const seats = {
reserve: jest.fn().mockRejectedValue(new Error('not authorized')),
assertAccount: jest.fn().mockRejectedValue(new Error('expired')),
attachDevice: jest.fn(),
};
return { ecom, seats, qr: new QrLoginService(db as never, ecom as never, seats as never) };
}
it('原生与内嵌均不能绕过授权', async () => {
const { qr, ecom } = build();
for (const auth of [user, { id: 'user', tenantId: 'tenant', hostContext: {} }])
await expect(qr.start(auth as never, { region: 'Zhejiang' })).rejects.toThrow(
'not authorized',
);
expect(ecom.platformCallRaw).not.toHaveBeenCalled();
});
it('重复点击复用设备', async () => {
const { qr, ecom, seats } = build();
seats.reserve.mockResolvedValue({ reservationId: 'trace', deviceId: 'device' });
await expect(qr.start(user, { region: 'Zhejiang' })).resolves.toEqual({
deviceId: 'device',
reused: true,
});
expect(ecom.platformCallRaw).not.toHaveBeenCalled();
});
it('到期不允许旧号重登', async () => {
const { qr, ecom } = build(true);
await expect(qr.start(user, { vid: 'vid' })).rejects.toThrow('expired');
expect(ecom.platformCallRaw).not.toHaveBeenCalled();
});
it.each(['human', 'agent'] as const)('到期拦截 %s 出站', async (actor) => {
const db = {
wecomAccount: { findFirst: jest.fn().mockResolvedValue({ status: 'online' }) },
hostingSeat: { findFirst: jest.fn().mockResolvedValue(null) },
};
await expect(
new GateService(db as never).check({ tenantId: 't', accountId: 'a', actor }),
).resolves.toMatchObject({ pass: false, gate: 'hosting_expired' });
});
it('直接写供应商接口也不可绕过', async () => {
const svc = new EcomService(
{
getOrThrow: () => ({
baseUrl: 'http://127.0.0.1:1',
accessId: 'test',
accessSecret: 'test',
timeoutMs: 10,
}),
} as never,
{ assertVid: jest.fn().mockRejectedValue(new Error('expired')) } as never,
);
await expect(svc.call(11053, { userId: 'vid', deviceId: 'd' } as never, {})).rejects.toThrow(
'expired',
);
await expect(svc.platformCallRaw('/wechatwork', { type: 11028, userId: 'vid', deviceId: 'd' })).rejects.toThrow('expired');
});
});
......@@ -94,3 +94,38 @@ describe('本地持久中转订阅', () => {
).rejects.toThrow('aborted');
});
});
describe('本地扫码路由', () => {
it('非本地模式不调中转;登记和归属检查使用独立调试凭据', async () => {
const { localLoginRoute } = await import('../src/modules/channel/ingress-pull.client');
const send = jest.fn().mockResolvedValue(new Response(null, { status: 204 }));
await localLoginRoute({ traceId: 'round' }, null, send);
expect(send).not.toHaveBeenCalled();
await localLoginRoute({ traceId: 'round' }, config, send);
await localLoginRoute({ traceId: 'round', deviceId: 'device' }, config, send);
await localLoginRoute({ deviceId: 'device', accountId: 'account' }, config, send);
expect(send.mock.calls.map((c) => c[0])).toEqual([
config.url + '/local/login',
config.url + '/local/login',
config.url + '/local/account',
]);
expect(send.mock.calls[0][1].headers.Authorization).toBe('Bearer ' + config.token);
});
it('中转不可用或归属冲突不能静默放行', async () => {
const { localLoginRoute } = await import('../src/modules/channel/ingress-pull.client');
await expect(
localLoginRoute(
{ traceId: 'round' },
config,
async () => new Response(null, { status: 503 }),
),
).rejects.toThrow('中转暂不可用');
await expect(
localLoginRoute(
{ deviceId: 'd', accountId: 'a' },
config,
async () => new Response(null, { status: 409 }),
),
).rejects.toThrow('其他环境');
});
});
......@@ -81,7 +81,7 @@ it('同一渠道跨诊所仍过滤单聊,混合患者群拒绝',async()=>{
const links=[{accountId:'acct',peerVid:'v-a',hostPatientId:'p-a'},{accountId:'acct',peerVid:'v-b',hostPatientId:'p-b'}];
const db={contactLink:{findMany:async()=>links},conversation:{findMany:async()=>[{id:'c-a',accountId:'acct',kind:'single',peerVid:'v-a',roomId:null},{id:'c-b',accountId:'acct',kind:'single',peerVid:'v-b',roomId:null},{id:'mixed',accountId:'acct',kind:'group',peerVid:null,roomId:'room'}]},wecomRoomMember:{findMany:async()=>[{accountId:'acct',roomId:'room',vid:'v-a'},{accountId:'acct',roomId:'room',vid:'v-b'}]}};
const service=new IntegrationAccessService(db as never,{getPatients:async()=>new Map([['p-a',{}]])} as never,{accountScope:async()=>({all:false,accountIds:['acct']})} as never);
expect(await service.conversationIds(user())).toEqual(['c-a']);expect(await service.contactKeys(user())).toEqual([{accountId:'acct',vid:'v-a'}]);
expect(await service.conversationIds(user())).toEqual(['c-a']);
});
it('未批准工作台模块时,深链也不绕过准入',async()=>{
const u=user();u.hostScope!.capabilities=['conversation.read'];u.hostScope!.modules=['appointment-confirm'];
......
......@@ -253,6 +253,7 @@ describe('⭐⭐ 发送:[11029] 文字,内部联系人', () => {
const h = new OutboundNotifyHandler(
{
wecomAccount: { findFirst: async () => ({ status: 'online', deviceId: 'd1', purpose: 'notifier', offlineReason: null }) },
hostingSeat: { findFirst: async () => ({ id: 'seat' }) },
// ⚠️ 撞限流时要停这个号的整条队列(`holdForRateLimit`)
$executeRaw: async () => (queuePaused++, 0),
} as never,
......
import { ConfigService } from '@nestjs/config';
import { OutboundAssetService } from '../src/modules/media/outbound-asset.service';
import { StorageService } from '../src/modules/media/storage.service';
import { OutboundMediaHandler } from '../src/modules/outbound/outbound-media.handler';
import type { ClaimedTask } from '../src/modules/outbox/outbox.types';
const oss = { region: 'oss-cn-hangzhou', bucket: 'test-bucket', accessKeyId: 'test-key', accessKeySecret: 'unused', prefix: 'test', urlTtlSeconds: 600 };
function fixture() {
const db = { attachment: { findFirst: jest.fn(async ({ where }: any) =>
where.tenantId === 'tenant-a' && where.id === 'attachment' ? { storageKey: 'tenant-a/image.png' } : null) } };
const assets = new OutboundAssetService(db as never, new StorageService(new ConfigService({ oss })));
return { db, assets };
}
afterEach(() => jest.restoreAllMocks());
it('没有应用公网地址也能生成私有 OSS 取件地址;重试时重新计算到期时间', async () => {
const { assets } = fixture();
const now = jest.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000);
const first = new URL(await assets.signedUrl('tenant-a', 'attachment'));
expect(first.hostname).toBe('test-bucket.oss-cn-hangzhou.aliyuncs.com');
expect(first.pathname).toBe('/test/tenant-a/image.png');
expect(first.searchParams.get('Expires')).toBe('1800000600');
expect(first.searchParams.get('Signature')).toBeTruthy();
now.mockReturnValue(1_800_000_060_000);
const retry = new URL(await assets.signedUrl('tenant-a', 'attachment'));
expect(retry.searchParams.get('Expires')).toBe('1800000660');
expect(retry.searchParams.get('Signature')).not.toBe(first.searchParams.get('Signature'));
});
it('其他租户的附件和不存在的附件都不能签名', async () => {
const { assets } = fixture();
await expect(assets.signedUrl('tenant-b', 'attachment')).rejects.toMatchObject({ msg: '附件不存在' });
await expect(assets.signedUrl('tenant-a', 'missing')).rejects.toMatchObject({ msg: '附件不存在' });
});
it.each([['image', 11030], ['file', 11031]] as const)('%s 投递等待异步签名后才调用 ECom,队列 payload 不存签名地址', async (kind, code) => {
const { assets } = fixture();
const call = jest.fn(async () => { throw new Error('stop-before-real-send'); });
const handler = new OutboundMediaHandler(
{ wecomAccount: { findUnique: async () => ({ deviceId: 'device' }) } } as never,
{ call } as never, { check: async () => ({ pass: true }) } as never,
{} as never, assets, {} as never,
);
const payload = { accountId: 'account', accountVid: '123', receiver: '456', attachmentId: 'attachment', kind, actor: 'human', isOutContact: true, fileName: 'test.pdf' };
await expect(handler.handle({ tenantId: 'tenant-a', payload } as unknown as ClaimedTask)).rejects.toThrow('stop-before-real-send');
expect(call).toHaveBeenCalledWith(code, { userId: '123', deviceId: 'device' }, expect.objectContaining({ url: expect.stringContaining('https://test-bucket.oss-cn-hangzhou.aliyuncs.com/') }));
expect(payload).not.toHaveProperty('url');
});
it('数据库临时故障向 worker 传播,不当作不可重试的配置错误', async () => {
const handler = new OutboundMediaHandler(
{ wecomAccount: { findUnique: async () => ({ deviceId: 'device' }) } } as never,
{} as never, { check: async () => ({ pass: true }) } as never,
{} as never, { signedUrl: async () => { throw new Error('database unavailable'); } } as never, {} as never,
);
await expect(handler.handle({ tenantId: 'tenant-a', payload: { accountId: 'a', receiver: '1', attachmentId: 'x' } } as unknown as ClaimedTask)).rejects.toThrow('database unavailable');
});
......@@ -116,7 +116,7 @@ describe('⛔ 全仓不许再按 role 判权限', () => {
*/
it('⭐ 只读闸是有意的例外,且必须还在', () => {
const guard = readFileSync(join(SRC, 'common', 'guards', 'jwt-auth.guard.ts'), 'utf8');
expect(guard).toMatch(/isReadOnly\(user\.role\)/);
expect(guard).toMatch(/isReadOnly\(grant\.role\)/);
expect(guard).toMatch(/READ_METHODS/);
expect(guard).toMatch(/ALLOW_VIEWER_WRITE/);
});
......@@ -189,3 +189,12 @@ describe('P2 组织与人员后台的权限点', () => {
expect(Object.values(PERMISSIONS)).not.toContain('org.ref.edit');
});
});
it('平台权限不混入客户角色和宿主能力契约', () => {
const { CAPABILITIES,roleCapabilities }=require('../src/modules/integration/integration.schema');
expect(CAPABILITIES.some((p:string)=>p.startsWith('platform.'))).toBe(false);
for (const role of ['owner','staff','viewer']) expect(permissionsOf(role).some(p=>p.startsWith('platform.'))).toBe(false);
expect(roleCapabilities('platform_admin')).toEqual([]);
expect(permissionsOf('platform_admin')).toContain(PERMISSIONS.PLATFORM_HOSTING_MANAGE);
expect(permissionsOf('platform_viewer')).not.toContain(PERMISSIONS.PLATFORM_HOSTING_MANAGE);
});
import { randomUUID } from 'node:crypto';
import { PlatformAdminService } from '../src/modules/platform/platform-admin.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { PasswordService } from '../src/modules/auth/password.service';
function fixture(count: number) {
const user = {
id: randomUUID(),
tenantId: null,
displayName: '平台人员',
isActive: true,
passwordHash: 'hash',
};
const tx = {
$queryRaw: jest.fn(async () => []),
user: { findUniqueOrThrow: jest.fn(async () => user) },
userRoleGrant: {
findUnique: jest.fn(async () => ({ role: 'platform_admin' })),
count: jest.fn(async () => count),
deleteMany: jest.fn(async () => ({ count: 1 })),
upsert: jest.fn(async () => ({})),
},
authSession: { updateMany: jest.fn(async () => ({ count: 1 })) },
userActivation: { updateMany: jest.fn(async () => ({ count: 1 })) },
auditLog: { create: jest.fn(async () => ({})) },
};
const db = {
$transaction: async (f: (t: typeof tx) => Promise<unknown>) => f(tx),
} as unknown as PrismaService;
return { user, tx, service: new PlatformAdminService(db, {} as PasswordService) };
}
it.each([{ revoke: true }, { role: 'platform_viewer' }])(
'不能撤掉或降级最后一位平台管理员:%j',
async (patch) => {
const f = fixture(1);
await expect(
f.service.execute({ userId: f.user.id, reason: '调整', ...patch }),
).rejects.toThrow('最后一位');
expect(f.tx.userRoleGrant.deleteMany).not.toHaveBeenCalled();
expect(f.tx.userRoleGrant.upsert).not.toHaveBeenCalled();
},
);
it('撤销平台授权只撤销平台会话,同时作废旧激活票并记统一审计', async () => {
const f = fixture(2);
await f.service.execute({ userId: f.user.id, reason: '离岗', revoke: true });
expect(f.tx.authSession.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: f.user.id, scope: 'platform', revokedAt: null } }),
);
expect(f.tx.userActivation.updateMany).toHaveBeenCalled();
expect(f.tx.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
scope: 'platform',
tenantId: null,
action: 'platform.user.revoke',
}),
}),
);
});
import { QrLoginService } from '../src/modules/auth/qr-login.service';
import { localLoginRoute } from '../src/modules/channel/ingress-pull.client';
jest.mock('../src/modules/channel/ingress-pull.client', () => ({ localLoginRoute: jest.fn() }));
describe('申请二维码前登记本地路由', () => {
const route = jest.mocked(localLoginRoute);
beforeEach(() => route.mockReset());
function fixture() {
const order: string[] = [];
const prisma = {
tenant: {
findUniqueOrThrow: jest.fn().mockResolvedValue({ id: 'tenant', status: 'active' }),
},
wecomAccount: { findMany: jest.fn().mockResolvedValue([]) },
auditLog: { create: jest.fn().mockResolvedValue({}) },
};
const ecom = {
platformCallRaw: jest.fn().mockImplementation(async () => {
order.push('ecom');
return { deviceId: 'device' };
}),
};
route.mockImplementation(async (p) => {
order.push('deviceId' in p ? 'device' : 'trace');
});
return { order, prisma, ecom, service: new QrLoginService(prisma as never, ecom as never, { reserve: jest.fn().mockResolvedValue({ reservationId: 'ai-login-test', deviceId: null }), attachDevice: jest.fn() } as never) };
}
it('请求追踪号先登记,ECom 回调可早于响应;再关联设备', async () => {
const { order, ecom, service } = fixture();
await service.start({ id: 'user', tenantId: 'tenant' } as never, { region: 'Zhejiang' });
expect(order).toEqual(['trace', 'ecom', 'device']);
const traceId = route.mock.calls[0]![0];
expect(ecom.platformCallRaw.mock.calls[0]![1]).toEqual(expect.objectContaining(traceId));
expect(route.mock.calls[1]![0]).toEqual({ ...traceId, deviceId: 'device' });
});
it('登记失败时不向 ECom 创建废扫码会话', async () => {
const { ecom, service } = fixture();
route.mockRejectedValueOnce(new Error('relay unavailable'));
await expect(
service.start({ id: 'user', tenantId: 'tenant' } as never, { region: 'Zhejiang' }),
).rejects.toThrow();
expect(ecom.platformCallRaw).not.toHaveBeenCalled();
});
});
......@@ -133,7 +133,7 @@ describe('受管模型清单 —— ⛔ 不许再漂', () => {
*/
it('⛔ 可空 / 无 tenant_id 的模型不受管', () => {
const notScoped = Prisma.dmmf.datamodel.models
.filter((m) => !m.fields.some((f) => f.name === 'tenantId' && f.isRequired))
.filter((m) => !['User','AuditLog','AuthSession','UserRoleGrant'].includes(m.name) && !m.fields.some((f) => f.name === 'tenantId' && f.isRequired))
.map((m) => m.name);
/**
* ⭐ `AiPause` 2026-09-06 加进这一档 —— 它**真的有跨租户的行**:
......@@ -183,3 +183,8 @@ describe('⭐⭐ 守卫真的装在 PrismaService 上了吗', () => {
expect(typeof svc.$queryRaw).toBe('function');
});
});
it.each(['User','AuditLog','AuthSession','UserRoleGrant'])('平台改造后的 %s 仍受租户守卫保护', model => {
expect(tenantGuardDecide({model,operation:'findMany',whereTenantId:undefined,ctxTenantId:T_A})).toBe('inject');
expect(tenantGuardDecide({model,operation:'findMany',whereTenantId:T_B,ctxTenantId:T_A})).toBe('conflict');
});
import { ExecutionContext } from '@nestjs/common';
import { defer, firstValueFrom } from 'rxjs';
import { TenantScopeInterceptor } from '../src/common/interceptors/tenant-scope.interceptor';
import { currentTenant } from '../src/common/tenant-context';
it('租户上下文覆盖 Observable 订阅及后续异步调用,并隔离并发请求', async () => {
const interceptor = new TenantScopeInterceptor();
const invoke = (tenantId: string) => {
const ctx = {
switchToHttp: () => ({ getRequest: () => ({ user: { id: 'user', tenantId } }) }),
} as ExecutionContext;
return firstValueFrom(
interceptor.intercept(ctx, {
handle: () =>
defer(async () => {
await Promise.resolve();
return currentTenant()?.tenantId;
}),
}),
);
};
expect(await Promise.all([invoke('a'), invoke('b')])).toEqual(['a', 'b']);
expect(currentTenant()).toBeUndefined();
});
......@@ -146,3 +146,14 @@ it('新宿主进入码不借用独立登录的旧凭据', async () => {
await expect(auth.accessToken()).rejects.toMatchObject({ code: 10104 });
expect(storage.getItem('friday.auth.session.' + a)).not.toBeNull();
});
it('平台账号连续刷新页面仍保留平台范围和空租户', async () => {
const platform = { ...tokens(), tenantId: null, scope: 'platform' };
await load().installSession(platform);
globalThis.fetch = jest.fn(async () => response({ ...platform, refreshToken: 'next' }));
expect(await load().accessToken()).toBe(platform.accessToken);
expect(await load().accessToken()).toBe(platform.accessToken);
expect(JSON.parse(storage.getItem('friday.auth.session.' + a)!)).toMatchObject({ scope: 'platform', tenantId: null });
Object.assign(fakeWindow, { location: { href: 'http://localhost:3103/platform', pathname: '/platform' } });
expect(load().loginDestination()).toBe('/platform/login');
});
......@@ -16,13 +16,16 @@
"@pac/utils": "workspace:*",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-slot": "^1.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.21.0",
"next": "^16.2.4",
"react": "^19.2.5",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
......
import { LoginForm } from '@/components/login-form';
export default function PlatformLoginPage() {
return <LoginForm platform />;
}
......@@ -142,6 +142,8 @@ export function AccountGate({
if (me?.hostContext && !hostAllows(me, 'channel.bind') && !me.accounts.length) return <div className="p-6 text-sm text-slate-500">这个账号尚未绑定企业微信渠道;当前宿主授权未开放绑定,请联系管理员。</div>;
if (!me) return <>{children}</>;
if (!me.accounts.length && !me.hosting?.canBind) return <div className="flex flex-1 items-center justify-center p-6"><div className="rounded-2xl bg-white p-8 text-center"><h2 className="font-semibold text-slate-800">尚未开通企业微信托管</h2><p className="mt-2 text-sm text-slate-500">请联系管理员开通托管席位后,再绑定企业微信号。</p></div></div>;
if (me.accounts.some((a) => a.hosting?.active === false)) return <div className="flex min-h-0 flex-1 flex-col"><div role="status" className="shrink-0 bg-amber-50 px-4 py-2 text-xs text-amber-700">部分企业微信号的托管席位未开通或已到期,已停止发送。历史记录仍可查看,请联系管理员续期。</div>{children}</div>;
if (me.accounts.length > 0 && !down && !allReleased) {
if (!troubled.length) return <>{children}</>;
// ⚠️ 横幅 `shrink-0`,工作台自己占满剩下的 —— ⛔ 别让它把列表挤出可视区
......
......@@ -9,7 +9,7 @@ export function AuthSessionBoundary({ children }: { children: ReactNode }) {
const pathname = usePathname();
useEffect(() => {
const changed = () => {
if (!currentSessionId() && pathname !== '/login' && pathname !== '/activate' && pathname !== '/embed')
if (!currentSessionId() && pathname !== '/platform/login' && pathname !== '/login' && pathname !== '/activate' && pathname !== '/embed')
router.replace(loginDestination());
};
persistWindowSelection();
......
'use client';
import { useEffect, useRef, useState, type Ref } from 'react';
import { ImageOff, Loader2, RotateCcw } from 'lucide-react';
import { ProtectedImage } from '@/components/protected-resource';
import { cn } from '@/lib/utils';
type Props = {
src: string;
previewSrc?: string;
large?: boolean;
onOpen?: () => void;
triggerRef?: Ref<HTMLButtonElement>;
};
/** 缩略图和大图共用加载状态;切图、重试都重新挂载,隔离迟到的资源事件。 */
export function ChatImage(props: Props) {
const [attempt, setAttempt] = useState(0);
return (
<ImageAttempt
key={`${props.src}:${attempt}`}
{...props}
refresh={attempt > 0}
onRetry={() => setAttempt((n) => n + 1)}
/>
);
}
function ImageAttempt({
src,
previewSrc,
large,
onOpen,
triggerRef,
refresh,
onRetry,
}: Props & {
refresh: boolean;
onRetry: () => void;
}) {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [previewReady, setPreviewReady] = useState(false);
const container = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(!!large);
useEffect(() => {
if (active || !container.current) return;
// 只加载靠近可视区的图片,避免翻历史时同时下载整段会话。
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setActive(true);
observer.disconnect();
}
},
{ rootMargin: '200px' },
);
observer.observe(container.current);
return () => observer.disconnect();
}, [active]);
// 包括取票据和图片下载,避免网络无响应时一直转圈。重试由用户触发。
useEffect(() => {
if (!active || state !== 'loading') return;
const timer = setTimeout(() => setState('error'), 30_000);
return () => clearTimeout(timer);
}, [active, state]);
return (
<div
ref={container}
aria-busy={state === 'loading'}
className={cn(
'relative isolate overflow-hidden rounded-lg',
large
? 'h-[min(72vh,720px)] w-full bg-slate-950/40 text-white'
: 'h-40 w-[210px] max-w-full bg-slate-100 text-slate-500',
)}
>
{large && previewSrc && previewSrc !== src && state !== 'ready' && (
<ProtectedImage
src={previewSrc}
alt=""
aria-hidden="true"
onLoad={() => setPreviewReady(true)}
className={cn(
'absolute inset-0 size-full object-contain blur-sm',
previewReady ? 'opacity-25' : 'opacity-0',
)}
/>
)}
{active && state !== 'error' && (
<ProtectedImage
src={src}
refresh={refresh}
alt={large ? '聊天图片大图' : '聊天图片'}
onLoad={() => setState('ready')}
onError={() => setState('error')}
className={cn(
'absolute inset-0 size-full object-contain transition-opacity duration-200 motion-reduce:transition-none',
state === 'ready' ? 'opacity-100' : 'opacity-0',
)}
/>
)}
{state === 'loading' && (
<div
role="status"
className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-xs"
>
<Loader2 aria-hidden="true" className="size-5 animate-spin motion-reduce:animate-none" />
<span>{large ? '加载大图…' : '加载图片…'}</span>
</div>
)}
{state === 'error' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-xs">
<ImageOff aria-hidden="true" className="size-6 opacity-60" />
<span role="status">图片暂时无法加载</span>
<button
type="button"
onClick={onRetry}
className="inline-flex items-center gap-1 rounded-md px-3 py-1.5 font-medium underline underline-offset-4 hover:bg-slate-400/15 focus-visible:outline-2 focus-visible:outline-offset-2"
>
<RotateCcw aria-hidden="true" className="size-3" />
重新加载
</button>
</div>
)}
{state === 'ready' && onOpen && (
<button
ref={triggerRef}
type="button"
aria-label="查看大图"
onClick={onOpen}
className="absolute inset-0 rounded-lg hover:bg-slate-900/5 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
/>
)}
</div>
);
}
'use client';
import { ProtectedImage, ProtectedVideo, ProtectedAudio, ProtectedFrame, ProtectedDownload } from '@/components/protected-resource';
import {
ProtectedImage,
ProtectedVideo,
ProtectedAudio,
ProtectedFrame,
ProtectedDownload,
} from '@/components/protected-resource';
import { Fragment, useState } from 'react';
import { Fragment, useRef, useState } from 'react';
import { ChatImage } from '@/components/chat-image';
import type { TimelineItem, TimelineMedia } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
......@@ -25,9 +32,7 @@ export function ChatTimeline({ items, group }: { items: readonly TimelineItem[];
const mine = m.side === 'me';
const isAi = mine && m.actor === 'agent';
// ⚠️ 和**上一条**比,不是和上一条同侧的比 —— 时间轴是一条,不分左右
const stamp = needsStamp(m.at, i > 0 ? items[i - 1]!.at : null)
? stampOf(m.at)
: null;
const stamp = needsStamp(m.at, i > 0 ? items[i - 1]!.at : null) ? stampOf(m.at) : null;
/*
系统条 —— **不做气泡**,就是一行居中的小灰字。
......@@ -185,7 +190,9 @@ function MessageBody({ item }: { item: TimelineItem }) {
库里存的永远是原文 —— 见 lib/wx-emoji。
⚠️ `whitespace-pre-wrap` ⛔ 能省:库里存的就是带换行的原文(提醒里「到店信息」那几段),
微信 / 企微按原样分段显示,这里⛔ 把回车压成空格(2026-09-22 用户截图对比撞出来)。 */}
{item.text ? <div className="whitespace-pre-wrap break-words">{renderWxEmoji(item.text)}</div> : null}
{item.text ? (
<div className="whitespace-pre-wrap break-words">{renderWxEmoji(item.text)}</div>
) : null}
{/*
表情 —— ⚠️ 走我们自己的 `/api/stickers/:id`,⛔ **不是**报文里那条
......@@ -314,29 +321,26 @@ function StickerImage({ messageId }: { messageId: string }) {
* 人得按浏览器的后退才能回来,而且看完那一屏什么上下文都没有。
*/
function ImageBubble({ show, open }: { show: TimelineMedia; open: TimelineMedia }) {
const [expanded, setExpanded] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
return (
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="block overflow-hidden rounded-lg bg-white transition hover:opacity-90"
aria-label="查看大图"
>
{/* ⚠️ 原生 <img> 不用 next/image:这些是**鉴权后**的动态字节,
浏览器原生资源通过限定路径票据读取。 */}
<ProtectedImage
src={`/api/attachments/${show.attachmentId}`}
alt={show.fileName || '患者发来的图片'}
loading="lazy"
className="max-h-48 max-w-[210px] object-contain"
/>
</button>
</DialogTrigger>
<DialogContent title={show.fileName || '查看图片'}>
<ProtectedImage
<Dialog open={expanded} onOpenChange={setExpanded}>
<ChatImage
src={`/api/attachments/${show.attachmentId}`}
triggerRef={trigger}
onOpen={() => setExpanded(true)}
/>
<DialogContent
title="查看图片"
onCloseAutoFocus={(e) => {
e.preventDefault();
trigger.current?.focus();
}}
>
<ChatImage
large
src={`/api/attachments/${open.attachmentId}`}
alt={show.fileName || '患者发来的图片'}
className="max-h-[86vh] w-auto self-center rounded-lg object-contain"
previewSrc={`/api/attachments/${show.attachmentId}`}
/>
<ProtectedDownload
href={`/api/attachments/${open.attachmentId}?download=1`}
......
......@@ -65,13 +65,7 @@ export function ConvDetail({
/** 这条会话上 Agent 交给人的待办([14 §1.2] Action 的 A 面) */
/** 人选了一个出口(B 面)。返回 false = 失败 */
/**
* 这个部署能不能发图片/文件(服务端 `capabilities.sendMedia`)。
*
* ⚠️⚠️ **配不上就不画这颗按钮**,而不是画一个点了会失败的。
* 判据是服务端有没有公网地址:ECom 的所有上传/发送接口**只收 URL**
* (全部文档扫过:没有 base64、没有 multipart),没有公网地址就根本发不出去。
* ⭐ 配置驱动而不是写死隐藏:配上那个 env 按钮自己就出来了,
* ⛔ 不用有人记得回来把注释掉的代码放开 —— 那种"记得"从来靠不住。
* 服务端声明媒体发送能力;本地与部署环境统一通过 OSS 提供短效取件地址。
*/
canSendMedia: boolean;
/**
......
......@@ -136,7 +136,7 @@ export function MyAccountsSheet({
const d = detailOf.get(a.vid);
const online = a.status === 'online';
/** ⚠️ banned / dismissed 扫码回不来 —— 给按钮就是骗人 */
const fixable = !online && (d?.needsScan || !!d?.verify);
const fixable = a.hosting?.active !== false && !online && (d?.needsScan || !!d?.verify);
/**
* ⭐⭐ **已解除的号要能接回来** —— 而且必须走**复用原设备**那条路。
*
......@@ -149,7 +149,7 @@ export function MyAccountsSheet({
* ⭐ `onBind(a.vid)` 走的是复用:`[11028]` 同时带 `userId + deviceId`
* (文档示例 ④「指定设备ID和UserId获取历史设备」)⇒ ECom **不会分配新设备**。
*/
const rehostable = !!a.releasedAt;
const rehostable = a.hosting?.active !== false && !!a.releasedAt;
/**
* ⭐ 已解除 / 解除中的号 ⛔ 不给「解除托管」入口。
* ⚠️ 在线与否**不影响** —— 恰恰是在线的号才需要解除;
......@@ -201,6 +201,15 @@ export function MyAccountsSheet({
? '需要安全验证'
: (d?.offlineReason ?? '已掉线')}
</span>
{a.hosting ? (
<span className={`text-[10.5px] ${a.hosting.active ? 'text-slate-400' : 'text-amber-600'}`}>
{!a.hosting.active
? '席位已停用 · 请联系管理员续期'
: a.hosting.expiresAt
? `托管至 ${new Date(a.hosting.expiresAt).toLocaleDateString('zh-CN')}`
: '永久托管'}
</span>
) : null}
</span>
<span className="ml-auto flex shrink-0 items-center gap-1.5">
{fixable ? (
......@@ -215,7 +224,7 @@ export function MyAccountsSheet({
variant="outline"
onClick={() => onBind(a.vid)}
// ⚠️ 说清它会**沿用**什么 —— 人换了城市时这句话是唯一的提示
title={`沿用原来的设备和区划(${a.region})。如果换了城市,请用下面的「绑定新的企业微信号」重新选区划`}
title={`沿用原来的设备和区划(${a.region})。需要换号或换区划请联系管理员`}
>
重新托管
</Button>
......@@ -365,10 +374,11 @@ export function MyAccountsSheet({
<Button
variant={me.accounts.length === 0 ? 'default' : 'outline'}
className="w-full"
disabled={!me.hosting?.canBind}
onClick={() => onBind(null)}
>
<Plus className="size-3.5" />
{me.accounts.length === 0 ? '绑定企业微信号' : '绑定新的企业微信号'}
{!me.hosting?.canBind ? '新增托管请联系管理员开通' : me.accounts.length === 0 ? '绑定企业微信号' : '绑定新的企业微信号'}
</Button>
{/*
⚠️⚠️ **加错了撤不掉** —— 说在按钮**旁边**,⛔ 不是点进去才说。
......@@ -660,7 +670,7 @@ function ReleasePanel({
<p className="rounded bg-white/70 px-2 py-1.5 text-[10.5px] leading-[1.7] text-slate-700">
以后接回来点「重新托管」就行,会沿用原来的设备和区划(<b>{region}</b>)。
<br />
如果这个号的手机<b>已经换到别的省</b>,就不要点它,请用「绑定新的企业微信号」重新选区划。
如果这个号的手机<b>已经换到别的省</b>,就不要点它,请联系管理员处理,勿自行新建托管。
</p>
<p className="text-[10.5px] leading-[1.7] text-slate-600">
只是想把号<b>交给同事</b>?请用「转交」,号不会下线,患者那边不会有任何变化。
......
'use client';
import { useCallback, useEffect, useState, useRef, type ComponentProps } from 'react';
import { api } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import { notify } from '@/components/ui/sonner';
import { AUTH_CHANGED, currentSessionId } from '@/lib/auth-session';
type Ticket = { url: string; expiresAt: number };
......@@ -36,14 +38,14 @@ if (typeof window !== 'undefined')
tickets.clear();
pending.clear();
});
function useResource(path: string) {
function useResource(path: string, refresh = false) {
const [value, setValue] = useState<Ticket | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let alive = true;
setValue(null);
setFailed(false);
void resource(path)
void resource(path, refresh)
.then((r) => {
if (alive) setValue(r);
})
......@@ -53,7 +55,7 @@ function useResource(path: string) {
return () => {
alive = false;
};
}, [path]);
}, [path, refresh]);
const retry = useCallback(() => {
if (value && value.expiresAt <= Date.now()) {
void resource(path, true)
......@@ -68,9 +70,10 @@ function useResource(path: string) {
export function ProtectedImage({
src,
onError,
refresh = false,
...props
}: ComponentProps<'img'> & { src: string }) {
const r = useResource(src);
}: ComponentProps<'img'> & { src: string; refresh?: boolean }) {
const r = useResource(src, refresh);
const image = useRef<HTMLImageElement>(null);
useEffect(() => {
if (r.failed) image.current?.dispatchEvent(new Event('error'));
......@@ -136,20 +139,31 @@ export function ProtectedDownload({
role="link"
tabIndex={0}
aria-disabled={busy}
aria-busy={busy}
onClick={(e) => {
e.preventDefault();
if (busy) return;
setBusy(true);
void resource(href, true)
.then((r) => window.location.assign(r.url))
.catch(() => {})
.catch((error) => notify.fail(error, '下载暂时失败,请重试'))
.finally(() => setBusy(false));
}}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.click();
}}
>
{children}
{busy ? (
<span role="status" className="inline-flex items-center gap-1.5">
<Loader2
aria-hidden="true"
className="size-3.5 animate-spin motion-reduce:animate-none"
/>
准备下载…
</span>
) : (
children
)}
</a>
);
}
......@@ -359,12 +359,12 @@ export function Sidebar({
*/
<button
type="button"
disabled={!onBind || !hostAllows(me, 'channel.bind')}
disabled={!onBind || !hostAllows(me, 'channel.bind') || !me?.hosting?.canBind}
onClick={onBind}
title={onBind ? '绑定企业微信号' : '到「AI 咨询」那一页可以绑定企业微信号'}
title={!me?.hosting?.canBind ? '请联系管理员开通托管席位' : onBind ? '绑定企业微信号' : '到「AI 咨询」那一页可以绑定企业微信号'}
className="truncate rounded text-left text-[10.5px] font-medium text-amber-600 enabled:hover:underline"
>
未绑定企微
{me?.hosting?.canBind ? '未绑定企微' : '托管待开通'}
</button>
) : (
/*
......
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { zhCN } from 'date-fns/locale';
import { cn } from '@/lib/utils';
import { buttonVariants } from './button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
const WEEKDAYS_ZH = ['日', '一', '二', '三', '四', '五', '六'];
// 中文格式化:星期"日一二…"、标题"2026年7月"、下拉"7月"/"2026年"。
const zhFormatters = {
formatWeekdayName: (d: Date) => WEEKDAYS_ZH[d.getDay()] ?? '',
formatCaption: (d: Date) => `${d.getFullYear()}年${d.getMonth() + 1}月`,
formatMonthDropdown: (d: Date) => `${d.getMonth() + 1}月`,
formatYearDropdown: (d: Date) => `${d.getFullYear()}年`,
};
/// shadcn 官方 Calendar(react-day-picker v10)—— 全 Tailwind classNames,不引 rdp 默认样式表。
/// 主题走项目 CSS 变量(--primary=brand-600 / --accent / --ring),故选中/今天/焦点自动是品牌色。
/// 支持 captionLayout="dropdown"(月/年下拉)与默认 label(左右翻页箭头)两种 caption。
/**
* 日历(react-day-picker 封装)— 单选/范围由 mode 决定。
* @category forms
*/
export function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
locale={zhCN}
weekStartsOn={0}
formatters={zhFormatters}
className={cn('relative p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row gap-2',
month: 'relative flex flex-col gap-4',
month_caption: 'flex justify-center pt-1 items-center h-9',
caption_label: 'text-[12px] font-medium',
// 月/年下拉(captionLayout="dropdown")
dropdowns: 'flex items-center justify-center gap-1.5 text-[12px] font-medium',
dropdown_root:
'relative inline-flex items-center gap-1 rounded-md bg-slate-100 px-2 py-1 hover:bg-accent focus-within:ring-2 focus-within:ring-ring',
dropdown: 'absolute inset-0 h-full w-full cursor-pointer opacity-0',
// 左右翻页箭头:nav 绝对定位横跨日历宽度、两端对齐,箭头落在 caption 两侧(锚到 relative root)。
// z-10 抬到 month_caption 之上(否则被居中的 caption 盖住导致点不动);容器 pointer-events-none
// 让空白中段的点击穿透到下方的月/年下拉,按钮各自 pointer-events-auto 可点。
nav: 'pointer-events-none absolute inset-x-3 top-3 z-10 flex items-center justify-between',
button_previous: cn(
buttonVariants({ variant: 'outline' }),
'pointer-events-auto size-7 bg-transparent p-0 opacity-60 hover:opacity-100',
),
button_next: cn(
buttonVariants({ variant: 'outline' }),
'pointer-events-auto size-7 bg-transparent p-0 opacity-60 hover:opacity-100',
),
// 日期网格
month_grid: 'w-full',
weekdays: 'flex',
weekday: 'w-8 rounded-md text-[11px] font-normal text-muted-foreground',
week: 'mt-1 flex w-full',
day: 'relative size-8 p-0 text-center text-[12px] focus-within:relative focus-within:z-20',
day_button: cn(
buttonVariants({ variant: 'ghost' }),
'size-8 rounded-md p-0 font-normal aria-selected:opacity-100',
),
// 状态修饰(targeting 内层 button)
selected:
'[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground',
today: '[&>button]:font-semibold [&>button]:underline [&>button]:underline-offset-4',
outside: 'text-muted-foreground opacity-50',
disabled: 'text-muted-foreground opacity-40',
hidden: 'invisible',
...classNames,
}}
components={{
Chevron: ({ orientation, className: c, ...rest }) => {
const Icon =
orientation === 'left'
? ChevronLeft
: orientation === 'right'
? ChevronRight
: ChevronDown;
return <Icon className={cn('size-4', c)} {...rest} />;
},
}}
{...props}
/>
);
}
'use client';
import { useState } from 'react';
import { CalendarDays } from 'lucide-react';
import { Button } from './button';
import { Calendar } from './calendar';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
const pad = (value: number) => String(value).padStart(2, '0');
const hours = Array.from({ length: 24 }, (_, i) => pad(i));
const minutes = Array.from({ length: 60 }, (_, i) => pad(i));
/** shadcn 日期与时间组合;value 保持本地 YYYY-MM-DDTHH:mm,由业务提交时转 UTC。 */
export function DateTimePicker({
value,
onChange,
label = '日期时间',
}: {
value: string;
onChange: (value: string) => void;
label?: string;
}) {
const [open, setOpen] = useState(false);
const [datePart, timePart = '00:00'] = value.split('T');
const [hour, minute] = timePart.split(':');
const selected = datePart ? new Date(`${datePart}T00:00:00`) : undefined;
return (
<div role="group" aria-label={label} className="flex flex-wrap items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-label={`${label}日期`}
className="min-w-36 flex-1 justify-between font-normal"
>
{datePart ? datePart.replaceAll('-', '/') : '选择日期'}
<CalendarDays className="size-3.5 text-slate-400" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0">
<Calendar
mode="single"
selected={selected}
defaultMonth={selected}
required
onSelect={(date) => {
onChange(
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${timePart}`,
);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
<Select
value={hour}
disabled={!datePart}
onValueChange={(next) => onChange(`${datePart}T${next}:${minute}`)}
>
<SelectTrigger aria-label={`${label}小时`} className="w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[100]">
{hours.map((h) => (
<SelectItem key={h} value={h}>
{h} 时
</SelectItem>
))}
</SelectContent>
</Select>
<span aria-hidden="true" className="text-slate-400">
:
</span>
<Select
value={minute}
disabled={!datePart}
onValueChange={(next) => onChange(`${datePart}T${hour}:${next}`)}
>
<SelectTrigger aria-label={`${label}分钟`} className="w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[100]">
{minutes.map((m) => (
<SelectItem key={m} value={m}>
{m} 分
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
......@@ -23,15 +23,26 @@ const DialogClose = DialogPrimitive.Close;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { title: string }
>(({ className, children, title, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
title: string;
variant?: 'media' | 'form';
}
>(({ className, children, title, variant = 'media', ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[80] bg-slate-900/70" />
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-[80]',
variant === 'form' ? 'bg-foreground/25' : 'bg-slate-900/70',
)}
/>
<DialogPrimitive.Content
ref={ref}
aria-describedby={undefined}
className={cn(
'fixed top-1/2 left-1/2 z-[80] flex max-h-[92vh] w-[min(92vw,1100px)] -translate-x-1/2 -translate-y-1/2 flex-col',
'data-[state=open]:animate-overlayIn data-[state=closed]:animate-overlayOut',
variant === 'form' &&
'w-[min(92vw,420px)] overflow-y-auto rounded-xl bg-white p-5 text-[12.5px] leading-relaxed text-slate-700 shadow-xl',
className,
)}
{...props}
......@@ -41,11 +52,20 @@ const DialogContent = React.forwardRef<
(而且读屏器打开时不知道这是什么)。看图弹层视觉上不需要标题条,
⇒ 标题给到 `sr-only`,⛔ 不是省略。
*/}
<DialogPrimitive.Title className="sr-only">{title}</DialogPrimitive.Title>
<DialogPrimitive.Title
className={variant === 'form' ? 'pr-8 text-[14px] font-semibold text-slate-900' : 'sr-only'}
>
{title}
</DialogPrimitive.Title>
{children}
<DialogPrimitive.Close
aria-label="关闭"
className="absolute -top-9 right-0 grid size-7 place-items-center rounded-full bg-white/15 text-white transition hover:bg-white/30"
className={cn(
'absolute grid place-items-center transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
variant === 'form'
? 'top-4 right-4 size-7 rounded text-slate-400 hover:bg-slate-100 hover:text-slate-600'
: '-top-9 right-0 size-7 rounded-full bg-white/15 text-white hover:bg-white/30',
)}
>
<X className="size-4" />
</DialogPrimitive.Close>
......
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
/**
* 浮层 — Popover + PopoverTrigger/PopoverContent 组合。
* @category overlay
*/
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-[90] w-auto max-w-[calc(100vw-24px)] rounded-xl bg-white p-2 text-[12.5px] text-slate-700 shadow-xl outline-none',
// ⚠️ 原先挂的是 shadcn 官方那串 `animate-in fade-in-0 …` —— 它们来自
// tailwindcss-animate 插件,**本仓没装**,所以一帧动画都没有(class 写了不报错,
// 只是不生效,最难发现的那种)。改用 globals.css 里的原生 keyframes。
// 缩放锚点跟随 Radix 算出来的位置 → 浮层从触发按钮那个角"长出来",而不是自身中心缩放
'origin-(--radix-popover-content-transform-origin)',
'data-[state=open]:animate-overlayIn data-[state=closed]:animate-overlayOut',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };
......@@ -51,6 +51,7 @@ interface Entry {
* ⇒ 判「要不要走重新托管那条路」必须看 **`accounts.length`**,⛔ 不是这个字段。
*/
bound: boolean;
hosting: { canBind: boolean; bindExpiresAt: string | null };
}
const PHASE_UI: Record<QrStatus['phase'], { text: string; dot: string }> = {
......@@ -149,7 +150,7 @@ export function WecomBind({
setDeviceId(r.deviceId);
// ⚠️ 后端可能没真下新单:上一轮还没到终态时它会把那一轮还回来(避免设备锁定降级)。
// 不说出来的话,人点了刷新看到码没变,会以为按钮坏了、继续点 —— 而那正是降级的成因。
setNotice(r.reused ? '当前二维码仍然有效,继续用它扫就行' : null);
setNotice(r.reused ? '已复用本次绑号请求;若二维码已失效,请联系管理员重新授权' : null);
} catch (e) {
setError(e instanceof ApiError ? e.message : '无法获取二维码');
setStatus({ phase: 'failed', hint: '请稍后重试' });
......@@ -168,6 +169,11 @@ export function WecomBind({
.then((r) => {
if (!alive) return;
setEntry(r);
if ((addNew || r.accounts.length === 0) && !r.hosting?.canBind) {
setError('尚未获得新增绑号授权,请联系管理员开通托管席位');
setStage('error');
return;
}
if (addNew) {
/**
* ⭐ 加新号走**首次绑定**那条路:人自己选区划、不传 vid(= 新设备)。
......
......@@ -56,7 +56,7 @@ async function call<T>(path: string, init?: RequestInit): Promise<T> {
const body = (await res.json()) as Envelope<T>;
if (body.code !== CODE_OK) throw new ApiError(body.code, body.msg, body.details);
if (entering) await installSession(body.data as T & SessionTokens);
if (path === '/auth/password') clearSession();
if (path === '/auth/password' || path === '/platform/password') clearSession();
return body.data as T;
} catch (e) {
if (e instanceof SessionError) throw new ApiError(e.code, e.message);
......@@ -378,12 +378,14 @@ export interface Me {
account: { vid: string; nickname: string | null; status: string; sendingEnabled: boolean } | null;
/** 他名下的托管号,按绑定先后。⚠️ 可能 0 个(还没绑)、也可能多个。 */
/** ⭐ `id` 是**动作地址**(转交/选号);`vid` 是企微那边的号,⛔ 别混用 */
hosting?: { canBind: boolean; bindExpiresAt: string | null };
accounts: {
id: string;
vid: string;
nickname: string | null;
status: string;
sendingEnabled: boolean;
hosting?: { active: boolean; expiresAt: string | null };
/**
* ⭐⭐ 区划码(如 `Zhejiang`)—— **和设备号是一对**。
* 重新托管会**沿用**它;换一个 = 企微眼里的异地登录 = 风控。
......@@ -420,10 +422,7 @@ export interface Me {
*/
capabilities?: {
/**
* 能不能发图片/文件。
* ⚠️ 判据是服务端配没配 `AI_PUBLIC_BASE_URL` —— ECom 的所有上传/发送接口
* **只收 URL**(全部文档扫过:没有 base64、没有 multipart),
* 没有公网地址就根本发不出去,所以按钮也不该出现。
* 能不能发图片/文件;统一使用 OSS,不依赖应用公网地址。
*/
sendMedia: boolean;
};
......
......@@ -7,7 +7,8 @@ export interface SessionTokens {
context?: HostEntryContext;
sessionId: string;
userId: string;
tenantId: string;
tenantId: string | null;
scope?: string;
accessToken: string;
accessExpiresAt: string;
refreshToken: string;
......@@ -17,7 +18,8 @@ interface StoredSession {
context?: HostEntryContext;
sessionId: string;
userId: string;
tenantId: string;
tenantId: string | null;
scope?: string;
refreshToken: string;
refreshExpiresAt: string;
pendingRequestId?: string;
......@@ -76,7 +78,8 @@ export function persistWindowSelection(): void {
const url = new URL(window.location.href);
if (url.searchParams.get('sid') !== id) { url.searchParams.set('sid', id); window.history.replaceState(window.history.state, '', url); }
}
export function loginDestination(): '/embed' | '/login' {
export function loginDestination(): '/embed' | '/login' | '/platform/login' {
if (typeof window !== 'undefined' && window.location?.pathname?.startsWith('/platform')) return '/platform/login';
// 宿主窗口的 sid 不是凭据;授权被别的窗口清掉后仍能辨认这个入口。
const hostWindow = typeof window !== 'undefined' && window.location?.href
&& new URL(window.location.href).searchParams.has('sid');
......@@ -93,7 +96,7 @@ function read(id: string): StoredSession | null {
return r.sessionId === id &&
typeof r.refreshToken === 'string' &&
typeof r.userId === 'string' &&
typeof r.tenantId === 'string' &&
(typeof r.tenantId === 'string' || (r.tenantId === null && r.scope === 'platform')) &&
Number.isFinite(Date.parse(r.refreshExpiresAt))
? r
: null;
......@@ -133,6 +136,7 @@ export async function installSession(r: SessionTokens): Promise<void> {
sessionId: r.sessionId,
userId: r.userId,
tenantId: r.tenantId,
scope: r.scope,
refreshToken: r.refreshToken,
refreshExpiresAt: r.refreshExpiresAt,
context: r.context,
......@@ -191,6 +195,7 @@ async function rotate(id: string): Promise<string> {
sessionId: next.sessionId,
userId: next.userId,
tenantId: next.tenantId,
scope: next.scope,
refreshToken: next.refreshToken,
refreshExpiresAt: next.refreshExpiresAt,
context: r.context,
......
......@@ -43,7 +43,7 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): Config {
const localToken = env.INGRESS_LOCAL_TOKEN ?? '';
if (localAccounts.includes('*') && localAccounts.length !== 1)
throw Error('本地账号列表中的 * 必须单独使用');
if (localAccounts.length && (!/^[A-Za-z0-9_-]{32,}$/.test(localToken) || localToken === token)) {
if ((localAccounts.length || localToken) && (!/^[A-Za-z0-9_-]{32,}$/.test(localToken) || localToken === token)) {
throw Error('本地调试须配置独立的 INGRESS_LOCAL_TOKEN,至少 32 位');
}
return {
......
import type { Pool, PoolClient } from 'pg';
export const loginRouteSchema = `
CREATE TABLE IF NOT EXISTS ingress.local_login_routes (
trace_id text PRIMARY KEY,
device_id text,
expires_at timestamptz NOT NULL DEFAULT (now()+interval '30 minutes')
);
CREATE INDEX IF NOT EXISTS ix_local_login_device ON ingress.local_login_routes(device_id);
CREATE TABLE IF NOT EXISTS ingress.account_routes (
account_id text PRIMARY KEY,
destination text NOT NULL CHECK (destination IN ('local','server'))
);
-- 保留迁移前已经交给服务器的账号归属,避免本地扫码意外接管。
INSERT INTO ingress.account_routes(account_id,destination)
SELECT route_key, 'server'
FROM ingress.deliveries WHERE route_key NOT IN ('0','unknown','') AND route_key NOT LIKE 'device:%' AND destination='server'
GROUP BY route_key ON CONFLICT DO NOTHING;
`;
export class RouteConflict extends Error {}
const text = (v: unknown): string =>
typeof v === 'string' || typeof v === 'number' ? String(v) : '';
const account = (v: unknown): string => {
const s = text(v);
return s && s !== '0' ? s : '';
};
export async function registerLocalLogin(
pool: Pool,
traceId: string,
deviceId?: string,
): Promise<void> {
if (deviceId) {
const r = await pool.query(
`UPDATE ingress.local_login_routes SET device_id=$2
WHERE trace_id=$1 AND expires_at>now() AND (device_id IS NULL OR device_id=$2) RETURNING trace_id`,
[traceId, deviceId],
);
if (!r.rowCount) throw new RouteConflict('扫码会话已过期或设备不匹配');
} else {
// 重试幂等,但不能靠重复登记无限延长旧扫码会话。
await pool.query(
'INSERT INTO ingress.local_login_routes(trace_id) VALUES($1) ON CONFLICT DO NOTHING',
[traceId],
);
}
}
/** 回调归属与入队同一事务;账号锁防止并发登录和首条消息分到两端。 */
export async function callbackRoute(
tx: PoolClient,
body: Record<string, unknown>,
localAccounts: string[],
) {
const data =
body.data && typeof body.data === 'object' ? (body.data as Record<string, unknown>) : {};
const kind = Number(body.type);
const loginEvent = [11028, 15002, 11026].includes(kind);
const vid = account(body.userId) || (loginEvent ? account(data.userId) : '');
const device = text(body.deviceId);
const trace = text(body.traceId);
const routes = await tx.query(
`SELECT trace_id FROM ingress.local_login_routes
WHERE expires_at>now() AND (trace_id=$1 OR (device_id=$2 AND $2<>''))
ORDER BY expires_at DESC LIMIT 1 FOR UPDATE`,
[trace, device],
);
const round = routes.rows[0] as { trace_id: string } | undefined;
if (round && device && kind === 11028) {
await tx.query(
`UPDATE ingress.local_login_routes SET device_id=$2
WHERE trace_id=$1 AND (device_id IS NULL OR device_id=$2)`,
[round.trace_id, device],
);
}
let destination =
localAccounts.includes('*') || (vid && localAccounts.includes(vid)) ? 'local' : 'server';
if (vid) {
await tx.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [
'ingress-account:' + vid,
]);
const known = await tx.query(
'SELECT destination FROM ingress.account_routes WHERE account_id=$1',
[vid],
);
if (known.rows[0]?.destination === 'local') destination = 'local';
// 已在服务端使用的账号不被本地扫码挪走。登录事件仍回发起端用于展示状态。
if (!known.rowCount && round) destination = 'local';
if (
!known.rowCount &&
(!loginEvent || kind === 11026) &&
!(destination === 'local' && (localAccounts.includes('*') || localAccounts.includes(vid)))
) {
await tx.query('INSERT INTO ingress.account_routes(account_id,destination) VALUES($1,$2)', [
vid,
destination,
]);
}
}
if (round && loginEvent) destination = 'local';
return { destination, route: loginEvent && device ? 'device:' + device : vid || 'unknown' };
}
export async function assertLocalAccount(
pool: Pool,
deviceId: string,
accountId: string,
localAccounts: string[],
): Promise<void> {
const r = await pool.query(
`SELECT 1 FROM ingress.local_login_routes l
WHERE l.device_id=$1 AND l.expires_at>now() AND
($3::boolean OR EXISTS(SELECT 1 FROM ingress.account_routes a WHERE a.account_id=$2 AND a.destination='local'))`,
[deviceId, accountId, localAccounts.includes('*') || localAccounts.includes(accountId)],
);
if (!r.rowCount) throw new RouteConflict('该账号属于其他环境或扫码会话已过期');
}
......@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage } from 'node:http';
import { timingSafeEqual } from 'node:crypto';
import type { Config } from './config';
import type { Store } from './store';
import { RouteConflict } from './login-routes';
// 沿用旧中转 20MB 接收上限。AI 拒收的大报文仍保留在队列,不应在入口丢弃。
export const MAX_BODY = 20 * 1024 * 1024;
......@@ -15,7 +16,7 @@ export function createIngressServer(store: Store, config: Config) {
return createServer(async (req, res) => {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
const path = (req.url ?? '').split('?')[0];
const path = (req.url ?? '').split('?')[0] ?? '';
try {
if (req.method === 'GET' && path === '/health') {
await store.stats();
......@@ -28,8 +29,11 @@ export function createIngressServer(store: Store, config: Config) {
res.end(JSON.stringify(await store.stats()));
return;
}
if (req.method === 'POST' && (path === '/local/pull' || path === '/local/ack')) {
if (!config.localAccounts.length || !config.localToken || !bearer(req, config.localToken)) {
if (
req.method === 'POST' &&
['/local/pull', '/local/ack', '/local/login', '/local/account'].includes(path)
) {
if (!config.localToken || !bearer(req, config.localToken)) {
res.writeHead(404).end('not found');
return;
}
......@@ -56,13 +60,45 @@ export function createIngressServer(store: Store, config: Config) {
}
parts.push(bytes);
}
let body: { deliveryId?: unknown; lease?: unknown } | null;
let body: {
deliveryId?: unknown;
lease?: unknown;
traceId?: unknown;
deviceId?: unknown;
accountId?: unknown;
} | null;
try {
body = JSON.parse(Buffer.concat(parts).toString('utf8'));
} catch {
res.writeHead(400).end('invalid acknowledgement');
return;
}
if (path === '/local/login' || path === '/local/account') {
const valid = (v: unknown): v is string =>
typeof v === 'string' && /^[A-Za-z0-9_-]{1,160}$/.test(v);
if (
!body ||
(path === '/local/login'
? !valid(body.traceId) || (body.deviceId !== undefined && !valid(body.deviceId))
: !valid(body.deviceId) || !valid(body.accountId) || body.accountId === '0')
) {
res.writeHead(400).end('invalid routing request');
return;
}
try {
if (path === '/local/login')
await store.registerLocalLogin(
body.traceId as string,
body.deviceId as string | undefined,
);
else await store.assertLocalAccount(body.deviceId as string, body.accountId as string);
res.writeHead(204).end();
} catch (error) {
if (!(error instanceof RouteConflict)) throw error;
res.writeHead(409).end('routing conflict');
}
return;
}
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (
!body ||
......@@ -86,7 +122,8 @@ export function createIngressServer(store: Store, config: Config) {
peer === '127.0.0.1' ||
peer === '::1' ||
/^172\.(1[6-9]|2[0-9]|3[01])\./.test(peer) ||
peer.startsWith('10.');
peer.startsWith('10.') ||
peer.startsWith('192.168.');
const ip =
trustedProxy && typeof req.headers['x-real-ip'] === 'string'
? req.headers['x-real-ip']
......
......@@ -2,6 +2,13 @@ import { randomUUID } from 'node:crypto';
import { Pool } from 'pg';
import JSONbig from 'json-bigint';
import {
loginRouteSchema,
callbackRoute,
registerLocalLogin,
assertLocalAccount,
} from './login-routes';
const json = JSONbig({ storeAsString: true });
export interface Delivery {
id: string;
......@@ -16,6 +23,8 @@ export interface QueueStats {
}
export interface Store {
append(rawBody: string): Promise<string>;
registerLocalLogin(traceId: string, deviceId?: string): Promise<void>;
assertLocalAccount(deviceId: string, accountId: string): Promise<void>;
claim(destination?: 'server' | 'local'): Promise<Delivery | null>;
acknowledgeLocal(id: string, lease: string): Promise<boolean>;
delivered(item: Delivery): Promise<void>;
......@@ -43,6 +52,7 @@ CREATE INDEX IF NOT EXISTS ix_ingress_route ON ingress.deliveries (route_key, se
CREATE INDEX IF NOT EXISTS ix_ingress_delivered ON ingress.deliveries (delivered_at) WHERE delivered_at IS NOT NULL;
ALTER TABLE ingress.deliveries ADD COLUMN IF NOT EXISTS destination text NOT NULL DEFAULT 'server'
CHECK (destination IN ('server', 'local'));
${loginRouteSchema}
`;
export class PgStore implements Store {
......@@ -54,32 +64,47 @@ export class PgStore implements Store {
async verifyRouting(): Promise<void> {
// 部署不偷偷转移旧积压。先排空旧端再改归属,避免同账号横跨两个业务库。
const result = await this.pool.query(
`SELECT 1 FROM ingress.deliveries WHERE delivered_at IS NULL
AND destination <> CASE WHEN $1::boolean OR route_key=ANY($2::text[]) THEN 'local' ELSE 'server' END LIMIT 1`,
`SELECT 1 FROM ingress.deliveries WHERE delivered_at IS NULL AND route_key NOT LIKE 'device:%'
AND destination <> CASE WHEN $1::boolean OR route_key=ANY($2::text[])
OR EXISTS(SELECT 1 FROM ingress.account_routes a WHERE a.account_id=route_key AND a.destination='local') THEN 'local' ELSE 'server' END LIMIT 1`,
[this.localAccounts.includes('*'), this.localAccounts],
);
if (result.rowCount) throw Error('存在与新路由冲突的积压,须先在原处理端排空再切换');
}
registerLocalLogin(traceId: string, deviceId?: string): Promise<void> {
return registerLocalLogin(this.pool, traceId, deviceId);
}
assertLocalAccount(deviceId: string, accountId: string): Promise<void> {
return assertLocalAccount(this.pool, deviceId, accountId, this.localAccounts);
}
async append(rawBody: string): Promise<string> {
let route = 'unknown';
let body: Record<string, unknown> = {};
try {
const body = json.parse(rawBody);
if (body?.userId != null) route = String(body.userId);
const parsed = json.parse(rawBody);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) body = parsed;
} catch {
/* 无法解析也保存原文,由 AI 留档。 */
}
const id = randomUUID();
await this.pool.query(
'INSERT INTO ingress.deliveries (id, route_key, raw_body, destination) VALUES ($1,$2,$3,$4)',
[
id,
route,
rawBody,
this.localAccounts.includes('*') || this.localAccounts.includes(route) ? 'local' : 'server',
],
);
return id;
const tx = await this.pool.connect();
try {
await tx.query('BEGIN');
const { route, destination } = await callbackRoute(tx, body, this.localAccounts);
await tx.query(
'INSERT INTO ingress.deliveries (id,route_key,raw_body,destination) VALUES($1,$2,$3,$4)',
[id, route, rawBody, destination],
);
await tx.query('COMMIT');
return id;
} catch (error) {
await tx.query('ROLLBACK');
throw error;
} finally {
tx.release();
}
}
async claim(destination: 'server' | 'local' = 'server'): Promise<Delivery | null> {
......@@ -138,6 +163,7 @@ export class PgStore implements Store {
}
async prune(): Promise<void> {
await this.pool.query('DELETE FROM ingress.local_login_routes WHERE expires_at<now()');
// 只清理已交付记录;积压无论多旧均保留。小批量避免长事务。
await this.pool.query(`DELETE FROM ingress.deliveries WHERE id IN
(SELECT id FROM ingress.deliveries WHERE delivered_at<now()-interval '7 days' LIMIT 1000)`);
......
......@@ -23,6 +23,8 @@ const config: Config = {
localToken: '',
};
class MemoryStore implements Store {
async registerLocalLogin() {}
async assertLocalAccount() {}
items: Delivery[] = [];
failWrite = false;
retries = 0;
......@@ -52,6 +54,34 @@ class MemoryStore implements Store {
}
}
test('Docker 192.168 网桥识别 Nginx 来源头;公网连接不能伪造来源', async () => {
for (const [peer, expected] of [
['192.168.0.1', 200],
['::ffff:192.168.0.1', 200],
['198.51.100.1', 404],
] as const) {
const store = new MemoryStore();
const server = createIngressServer(store, config);
server.prependListener('request', (req) => {
Object.defineProperty(req.socket, 'remoteAddress', { value: peer, configurable: true });
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/hook/ecom`;
const response = await fetch(url, {
method: 'POST',
headers: { 'X-Real-IP': '192.0.2.12' },
body: '{}',
});
assert.equal(response.status, expected);
assert.equal(store.items.length, expected === 200 ? 1 : 0);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
});
test('只有成功保存才应答;保留原文和大整数;不接受伪造 XFF', async () => {
const store = new MemoryStore();
const server = createIngressServer(store, config);
......@@ -163,3 +193,37 @@ test(
}
},
);
test('扫码路由接口必须用本地密钥;校验入参并保留冲突状态', async () => {
const store = new MemoryStore();
const calls: unknown[] = [];
store.registerLocalLogin = async (...args: unknown[]) => {
calls.push(args);
};
const server = createIngressServer(store, {
...config,
localAccounts: ['123'],
localToken: 'b'.repeat(43),
});
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/local/login`;
const post = (token: string, body: unknown) =>
fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
});
try {
assert.equal((await post(config.token, { traceId: 'round' })).status, 404);
assert.equal((await post('b'.repeat(43), { traceId: 'bad space' })).status, 400);
assert.equal(calls.length, 0);
assert.equal(
(await post('b'.repeat(43), { traceId: 'round', deviceId: 'device' })).status,
204,
);
assert.deepEqual(calls, [['round', 'device']]);
} finally {
server.closeAllConnections();
await new Promise<void>((r) => server.close(() => r()));
}
});
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { createPool, PgStore, schema } from '../src/store';
test(
'扫码回调先于同步响应;设备关联、重启、账号归属隔离及过期',
{ skip: !process.env.INGRESS_TEST_DATABASE_URL },
async () => {
const url = new URL(process.env.INGRESS_TEST_DATABASE_URL!);
assert.ok(['localhost', '127.0.0.1'].includes(url.hostname));
assert.equal(url.pathname, '/friday_ai');
const pool = createPool(url.href);
const store = new PgStore(pool);
const trace = randomUUID(),
device = randomUUID(),
account = randomUUID();
const serverAccount = randomUUID(),
serverDevice = randomUUID(),
serverTrace = randomUUID();
const ids: string[] = [];
const append = async (body: unknown) => {
const id = await store.append(JSON.stringify(body));
ids.push(id);
return id;
};
const destination = async (id: string) =>
(await pool.query('SELECT destination FROM ingress.deliveries WHERE id=$1', [id])).rows[0]
.destination;
try {
await pool.query(schema);
await store.registerLocalLogin(trace);
const qr = await append({ type: 11028, userId: 0, traceId: trace, deviceId: device });
assert.equal(await destination(qr), 'local');
// 首条回调先于请求返回;重试登记同设备幂等。
await store.registerLocalLogin(trace, device);
await store.registerLocalLogin(trace, device);
await assert.rejects(store.registerLocalLogin(trace, randomUUID()));
assert.equal(
await destination(await append({ type: 15002, userId: 0, deviceId: device })),
'local',
);
assert.equal(
await destination(
await append({ type: 11026, userId: 0, deviceId: device, data: { userId: account } }),
),
'local',
);
await store.assertLocalAccount(device, account);
await assert.rejects(store.assertLocalAccount(device, serverAccount));
// 会话过期也不把已经归本地的账号交给服务器。
await pool.query(
"UPDATE ingress.local_login_routes SET expires_at=now()-interval '1 second' WHERE trace_id=$1",
[trace],
);
const restarted = new PgStore(pool);
ids.push(await restarted.append(JSON.stringify({ type: 11041, userId: account })));
assert.equal(await destination(ids[ids.length - 1]!), 'local');
await assert.rejects(restarted.registerLocalLogin(trace, device));
assert.equal(
await destination(await append({ type: 11028, userId: 0, deviceId: device })),
'server',
);
// 既有服务器账号的消息不能被本地扫码接管;绑定检查也应拒绝。
assert.equal(
await destination(await append({ type: 11041, userId: serverAccount })),
'server',
);
await store.registerLocalLogin(serverTrace);
await store.registerLocalLogin(serverTrace, serverDevice);
assert.equal(
await destination(
await append({ type: 11026, userId: serverAccount, deviceId: serverDevice }),
),
'local',
);
await assert.rejects(store.assertLocalAccount(serverDevice, serverAccount));
assert.equal(
await destination(
await append({ type: 11041, userId: serverAccount, deviceId: serverDevice }),
),
'server',
);
await store.verifyRouting();
// 不相关的测试环境二维码不被本地拉走。
assert.equal(
await destination(
await append({ type: 11028, userId: 0, deviceId: randomUUID(), traceId: randomUUID() }),
),
'server',
);
} finally {
await pool.query('DELETE FROM ingress.deliveries WHERE id=ANY($1::uuid[])', [ids]);
await pool.query('DELETE FROM ingress.local_login_routes WHERE trace_id=ANY($1::text[])', [
[trace, serverTrace],
]);
await pool.query('DELETE FROM ingress.account_routes WHERE account_id=ANY($1::text[])', [
[account, serverAccount],
]);
await pool.end();
}
},
);
......@@ -91,7 +91,7 @@ AI 自身租户为独立集团,固定客户组织根、组织树及 PAC 引用
- 范围跨多个独立集团、外组织映射重复或未知节点时拒绝;一次授权不能跨集团。停用集团不能被其他集团接替路由。
- 最终组织范围为宿主 orgScope 子树与 AI 当前常设授权的交集,永不超出客户根。映射或客户根改变后旧授权失效,不能把旧码换到新集团。
- AI 自己的权限、人员停用和人工授权不会被源登录覆盖。JIT 创建无本地密码的员工,不按名字或相同手机号合并人员;源姓名仅更新源管理的展示名。
- 企微渠道按现有账号归属单独校验。进入工作台不代表自动绑定企微,不代表能看同事渠道;限定组织下只展示能证明患者归属的联系人/会话,混合范围患者群不放行。
- 通讯录统一按 FridayAI 托管账号权限展示,不按登录来源或联系人是否关联患者过滤。患者资料、绑定患者及会话继续校验授权组织;混合范围患者群不放行。宿主登录不自动绑定企微,也不自动获得同事渠道权限。
### AI 角色
......
......@@ -96,22 +96,7 @@ export function checkAiEnvironment(api, pac, serviceDir) {
}
if (privateKey.asymmetricKeyType !== 'ed25519') throw Error('JWT 私钥必须为 Ed25519');
const warnings = [];
for (const key of ['AI_PUBLIC_BASE_URL']) {
if (!api[key]) continue;
const url = new URL(api[key]);
if (
url.protocol !== 'https:' ||
url.pathname !== '/' ||
url.search ||
url.hash ||
url.username ||
url.password
) {
throw Error(`${key} 必须是 HTTPS 根地址`);
}
}
if (!api.AI_LLM_API_KEY) warnings.push('未配置模型密钥,AI 推理不可用');
if (!api.AI_PUBLIC_BASE_URL) warnings.push('未配置公网取件地址,出站附件不可用');
if (api.AI_OUTBOX_WORKER !== 'off' || api.AI_SCHEDULER !== 'off') {
warnings.push('后台业务已启用;首次验收应先 off,确认专用测试账号后再开启');
}
......
......@@ -11,7 +11,7 @@ location = /hook/ecom {
}
# 本地主动拉取入口;鉴权由中转校验独立调试 token,不接受供应商 IP 白名单代替。
location ~ ^/local/(pull|ack)$ {
location ~ ^/local/(pull|ack|login|account)$ {
client_max_body_size 4k;
proxy_pass http://127.0.0.1:3106;
proxy_set_header Host $host;
......
......@@ -91,16 +91,13 @@ test('.env 是数据,不执行 shell;重复配置被拒绝', () => {
}
});
test('拒绝带前缀的 PAC 地址及非 HTTPS 的独立公网域名', () => {
test('拒绝带前缀的 PAC 地址', () => {
assert.throws(
() => checkAiEnvironment({ ...api, AI_PAC_BASE_URL: 'http://localhost:3101/pac/v1' }, pac, '.'),
/根地址/,
);
assert.throws(
() => checkAiEnvironment({ ...api, AI_PUBLIC_BASE_URL: 'http://localhost:3103' }, pac, '.'),
/HTTPS/,
);
assert.deepEqual(checkAiEnvironment(api, pac, '.'), ['未配置模型密钥,AI 推理不可用']);
});
test('false/空值不等于 off,后台任务须明确配置', () => {
......
......@@ -14,7 +14,7 @@ ecom → 公网中转 → 保存报文
## 一次性准备(部署新中转时)
1. 按 [中转部署步骤](channel-ingress-deployment.md) 安装新版本、迁移,并配置 `/local/pull` 和 `/local/ack` 两个 Nginx 路由。旧临时中转没有这两个接口,新版上线前继续使用旧 SSE 配置。
1. 按 [中转部署步骤](channel-ingress-deployment.md) 安装新版本、迁移,并配置 `/local/pull`、`/local/ack`、`/local/login`、`/local/account` 四个 Nginx 路由。旧临时中转没有这些接口,新版上线前继续使用旧 SSE 配置。
2. 指定专用调试企微账号的 **ecom `userId`**,对应 FridayAI 数据库 `app.wecom_accounts.vid`;不是员工手机号、租户 ID 或数据库内部 UUID。
3. 在服务器 `apps/channel-ingress/.env` 填写:
......@@ -52,20 +52,23 @@ pnpm --filter @pac/ai-web dev
## 首次扫码登录
还没有 ecom `userId` 时,二维码等回调无法按账号分配。仅在测试通道空闲时,将服务器配置暂设为:
本地申请二维码前自动登记唯一 `traceId`,中转据此关联 ECom 返回的 `deviceId`。二维码、扫码状态和登录成功事件回到发起的本地服务,无需临时改成 `*`。
```dotenv
INGRESS_LOCAL_ACCOUNTS=*
```
- 扫码路由保存于 `ingress.local_login_routes`,30 分钟过期;重启不丢失。
- 新账号登录成功后,归属保存于 `ingress.account_routes`,后续消息继续交给本地,扫码路由过期不改变账号归属。
- 已由服务器处理的账号不会被本地扫码接管;本地会拒绝完成绑定。
- 本地登记失败时不向 ECom 下单,避免界面一直等待不存在的回调。
- 仅配置有效的独立 `INGRESS_LOCAL_TOKEN` 即可启用首次扫码;`INGRESS_LOCAL_ACCOUNTS` 可以为空。未登记的其他扫码仍走服务器。
此时整条测试通道(含未识别账号的扫码回调)只交给本地,测试服务不接收。完成本地扫码、取得账号 `vid` 后,再按下面的切换步骤收窄为专用账号列表。不要在生产使用此模式。
首次升级此功能:先发布中转(含幂等迁移),更新上述 Nginx 路由并 reload,再更新本地 AI。测试 AI 不需要启用本地配置。
## 切换账号归属或结束调试
1. 暂停该测试账号的业务操作;在原处理端接收完未确认消息,并完成或暂停原端已入队的自动回复任务。
2. 修改 `INGRESS_LOCAL_ACCOUNTS`,重建中转容器。空值表示所有新消息交给测试服务。
3. 配置检查会拒绝“新归属与未交付消息冲突”的切换;出现此情况,保留原配置,先启动原处理端排空积压。不要手工修改消息 `destination` 或删除队列。
4. 只想暂时停本地接收:本地 `AI_INGRESS_SUBSCRIBER=off` 后重启后端。账号归属不变,消息继续等待本地。
2. 修改 `INGRESS_LOCAL_ACCOUNTS`,重建中转容器。该配置只控制显式配置的账号;扫码自动登记的账号还需在排空后调整 `ingress.account_routes` 的归属。空列表不会清除自动登记。
3. 自动登记的账号切换前,停止本地拉取及该账号操作,确认中转无未交付消息;在事务内锁定对应账号路由后,将其 `destination` 改为 `server`,并删除该账号设备仍有效的扫码路由。不得把修改消息队列归属当作账号切换。
4. 配置检查会拒绝“新归属与未交付消息冲突”的切换;出现此情况,保留原配置,先启动原处理端排空积压。不要手工修改消息 `destination` 或删除队列。
5. 只想暂时停本地接收:本地 `AI_INGRESS_SUBSCRIBER=off` 后重启后端。账号归属不变,消息继续等待本地。
中转不会清除另一业务库已有的会话、任务或定时任务。因此不要把正在自动接待的同一个企微号频繁在两个业务库间来回切换;日常开发固定使用专用调试号。
......
# FridayAI 企微托管席位
一个企微号一个席位,归属 FridayAI 租户,转交员工不改变席位。解除托管、到期、停用均不自动释放已绑定账号。客户管理员不能自行增加席位。
- 新席位默认从开通时间起一年;可指定期限或永久。
- 首次扫码另需分配给员工的绑定授权,默认 24 小时,最多 7 天,不超过席位到期时间。
- 到期/停用后禁止重新托管及对外发送,包括人工、AI、提醒号和加好友/群写操作;保留账号及历史数据,不自动退出 ECom 或删除设备。
- 在到期状态下处理到的出站任务会作废,续期后不自动重放。
- 存量账号在迁移时登记为永久席位,含已解除托管的账号。运营应核对期限后调整。
- 新增授权只允许一次设备申请。重复点击复用同一请求;网络超时或结果未知不自动释放,防止重复创建付费设备。
## 本地命令
在单仓库根目录执行。`tenant`、`user`、`account` 均使用 **FridayAI 数据库 UUID**,不是宿主源 ID;`account` 是企微账号记录 ID,不是 vid。
```sh
# 查看席位、账号占用、到期时间、授权和设备申请状态
pnpm --filter @pac/ai-service ai:hosting list --tenant=<租户UUID>
# 给员工开通一个年度席位,并授权其在 24 小时内首次扫码
pnpm --filter @pac/ai-service ai:hosting grant --tenant=<租户UUID> --user=<员工UUID> --reason='已确认开通'
# 短期试用:到期时间必须带时区
pnpm --filter @pac/ai-service ai:hosting grant --tenant=<租户UUID> --user=<员工UUID> --expires=2026-11-01T00:00:00+08:00 --reason='试用'
# 永久席位(可先不分配员工)
pnpm --filter @pac/ai-service ai:hosting grant --tenant=<租户UUID> --permanent --reason='永久开通'
# 将尚未绑定的席位授权给员工,授权超时也使用此命令重新分配
pnpm --filter @pac/ai-service ai:hosting authorize --tenant=<租户UUID> --seat=<席位UUID> --user=<员工UUID> --bind-hours=24 --reason='分配绑号授权'
# 续期一年:从当前到期日与现在的较晚者起算;已停用席位同时恢复
pnpm --filter @pac/ai-service ai:hosting renew --tenant=<租户UUID> --seat=<席位UUID> --reason='续期一年'
# 明确调整期限;永久席位调整成有期限也用此命令
pnpm --filter @pac/ai-service ai:hosting renew --tenant=<租户UUID> --seat=<席位UUID> --expires=2027-10-09T00:00:00Z --reason='核对合同期限'
# 停用:不解绑、不释放已占用席位,不调用 ECom 退出
pnpm --filter @pac/ai-service ai:hosting revoke --tenant=<租户UUID> --seat=<席位UUID> --reason='停止服务'
```
为通过运维工具登记、尚无席位的已有账号补开时,使用 `grant --tenant=... --account=... --reason=...`,可配 `--expires` 或 `--permanent`。已有席位不可重复开通。
## 二维码过期或请求结果未知
先核实 ECom 是否已产生设备/完成登录,不能把网络超时当作“未创建”。至少等待当前扫码轮次 15 分钟窗口结束;确认旧轮次已结束后,才重新授权:
```sh
pnpm --filter @pac/ai-service ai:hosting authorize --tenant=<租户UUID> --seat=<空席位UUID> --user=<员工UUID> --reset-binding --reason='已核实供应商旧轮次结束,允许重新申请设备'
```
已绑定席位不能用此命令换号;同号重登走页面“重新托管”,复用已有设备。换号先由运营核实供应商收费与设备状态,再决定新增席位和停用旧席位。
## 部署
随 AI 后端执行正常数据库迁移、生成 Prisma Client、构建并部署前后端;迁移为 `20261009150000_hosting_seats`。无新增环境变量,期限存 UTC,不新增定时任务。
容器中使用编译后的 CLI,参数与上面一致:
```sh
docker compose -p pac-ai -f docker-compose.ai.yml exec ai-service node dist/cli/hosting-seat.cli.js list --tenant=<租户UUID>
```
该 CLI 通过项目统一入口关闭 worker、scheduler 和中转订阅,不会在运维进程中发送消息。
所有开通、续期、授权、停用均记录 `hosting.*` 审计,包括原因、变更前后状态。FridayAI 席位到期不等于供应商停止收费;ECom 计费需另行核对。
## 验证
```sh
# 普通单元测试,不调用供应商
pnpm --filter @pac/ai-service test --runInBand hosting-seat.spec qr-ingress-route
# 使用现有本地库验证并发、事务和跨租户外键;仅接受 localhost/127.0.0.1
cd apps/ai-service
AI_HOSTING_DB_TEST=1 node --env-file=.env node_modules/jest/bin/jest.js --runInBand hosting-seat.integration
```
# FridayAI 平台管理
## 使用
页面:`/platform/login` → `/platform`。与客户工作台使用同一域名、同一服务。
平台管理首期提供:
- 客户列表、员工与企微号查询。
- 托管席位开通、续期、指定期限、永久、停用。
- 给员工授权首次扫码;默认 24 小时。
- 平台操作记录:操作人、目标客户、动作、原因、结果;最近 100 条。
开通一次新增一个席位。页面与 `ai:hosting` CLI 共用 `HostingAdminService`,不会因入口不同改变规则。解除托管、员工转交仍在原工作台操作。
客户开户、宿主接入配置和平台人员授权暂保留 CLI;没有开放平台用户自助注册或客户管理员提权入口。
## 开通平台账号
仓库根目录执行,使用 `apps/ai-service/.env` 指向的数据库。
创建一个不属于客户的新平台管理员(手机号必须未使用):
```sh
pnpm --filter @pac/ai-service ai:platform grant \
--phone=<手机号> --name=<姓名> --reason=平台管理员开户
```
命令仅本次输出随机临时密码。首次登录必须修改密码。不要将输出放入公共日志或提交到仓库。
已有账号保留原手机号和密码,增加平台授权:
```sh
pnpm --filter @pac/ai-service ai:platform grant \
--user=<用户UUID> --role=platform_admin --reason=开通平台管理
```
只读人员用 `--role=platform_viewer`。宿主创建但尚无密码的账号,先用现有 `ai:user --reset=<姓名>` 设置临时密码。
撤销平台授权:
```sh
pnpm --filter @pac/ai-service ai:platform revoke \
--user=<用户UUID> --reason=人员离岗
```
不能撤销或降级最后一名有效平台管理员。授权变更撤销该用户的平台会话,保留客户会话;改密和重置密码会使该账号所有范围的旧会话失效。
容器内对应入口:`node dist/cli/platform.cli.js`,参数相同。平台账号重置密码、解锁复用 `ai:user`,不另建密码体系。
## 数据与权限
| 对象 | 承载方式 |
| --- | --- |
| 登录身份 | 继续使用 `users`;纯平台人员的 `tenant_id` 为空 |
| 角色授权 | 新增 `user_role_grants`;`scope=tenant/platform` |
| 客户角色 | `owner/staff/viewer`,保留现有组织与数据范围规则 |
| 平台角色 | `platform_admin/platform_viewer` |
| 权限定义 | `common/permissions.ts`,不新增权限定义表 |
| 会话 | `auth_sessions.scope`;平台会话仅接受密码登录 |
| 审计 | 复用 `audit_logs`;新增 `scope`,`tenant_id` 表示目标客户,平台全局操作可为空 |
同一账号可以同时有客户角色和平台角色。当前每个范围只允许一个角色;不开放多客户成员身份。平台授权不会自动扩大客户会话中的患者数据范围,宿主同步只修改客户角色。
`user_role_grants` 是角色授权的写入来源。`users.role` 和 `integration_managed_role` 暂作兼容投影,由数据库触发器维护;独立更新会被拒绝。创建客户用户时保留初始角色入参,触发器在同一事务建立授权。迁移自动回填已有用户,不自动授予任何平台权限。
平台接口显式声明 `@AuthScope('platform')`;普通业务接口默认仅允许客户会话。平台管理接口不提供患者、会话内容或消息发送能力。客户查询统一审计时额外限制 `scope=tenant`,即使目标客户相同也不能读到平台记录。
平台登录复用密码强度、限流、锁定、改密和刷新机制。刷新凭据仍放 localStorage,短期访问令牌放内存。平台授权会作废旧客户激活票;客户激活链路不能重置平台人员密码。
## 发布
无需新增环境变量、数据库、端口或服务。继续使用 AI 独立数据库,日期存储为 UTC。
1. 功能分支验证后合入 `test`;生产仍等待测试验证后再合 `main`。
2. 备份 AI 数据库,构建与迁移版本一致的后端、前端产物。
3. 停止旧 AI 服务,运行 `prisma migrate deploy`,再启动新版本。
4. 迁移包括 `20261009150000_hosting_seats` 与 `20261009190000_platform_rbac`。
5. CLI 开通首名平台管理员,访问 `/platform/login`,改密后验证席位与审计页面。
6. 验证原客户登录、宿主内嵌、角色调整和通讯录功能。
不能迁移后只回滚旧代码:旧版角色更新会被投影保护触发器拒绝,旧版也不认识纯平台用户的空客户字段。失败时保持维护状态,修复后重启;如需数据库回滚,先备份故障现场,再使用部署前备份并核对期间写入。
仅访问平台页面不会请求 ECom 设备,也不会发送患者消息。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment