From bc7761d79e1c341e36a48d4a58281d1bf7d5f2d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E6=96=8C=E5=BC=BA?= Date: Mon, 14 Sep 2026 13:00:15 +0800 Subject: [PATCH 1/3] =?UTF-8?q?add=20.gitattributes:=20python=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=BB=9F=E4=B8=80LF=E6=8D=A2=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 1 + agent/advisor_agent/data_query.py | 2 ++ agent/advisor_agent/intent/classifier.py | 10 +++++- agent/advisor_agent/intent/recognizer.py | 11 ++++++ nl2sql/row_scope.py | 5 +-- service/nl2sql/query_service.py | 43 +++++++++++++++++++++--- 6 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0a7faf0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.py text eol=lf diff --git a/agent/advisor_agent/data_query.py b/agent/advisor_agent/data_query.py index d5d389c..e94e7d6 100644 --- a/agent/advisor_agent/data_query.py +++ b/agent/advisor_agent/data_query.py @@ -76,6 +76,7 @@ async def execute_advisor_data_query( question: str, trace_id: str, session_id: str | None = None, + conversation_context: str = "", data_scope: dict[str, Any] | None = None, max_rows: int | None = None, page: int = 1, @@ -162,6 +163,7 @@ async def execute_advisor_data_query( llm_client=llm_client, summary_llm=llm_client, masks=permission.get("masks"), + conversation_context=conversation_context, ) except (EmbeddingError, LLMFailError) as exc: raise QueryServiceError("投顾 Agent 依赖服务不可用,请检查 LLM/Embedding 服务连接") from exc diff --git a/agent/advisor_agent/intent/classifier.py b/agent/advisor_agent/intent/classifier.py index c8b0993..b4a6057 100644 --- a/agent/advisor_agent/intent/classifier.py +++ b/agent/advisor_agent/intent/classifier.py @@ -49,6 +49,7 @@ _CLASSIFIER_PROMPT = """你是基金投顾工作台的意图分类器,只负 """ _RECOMMENDATION_TERMS = ("推荐", "组合建议", "配置建议", "买什么", "适合配置", "筛选基金", "投资方案") +_SCOPE_ERROR_MESSAGE = "投顾范围查询仅支持客户数据查询" def _rule_fallback(query: str | None) -> IntentClassification: @@ -94,6 +95,13 @@ async def classify_advisor_intent( return IntentClassification(explicit_intent, 1.0, "explicit", "客户端显式指定") if not query or not query.strip(): return IntentClassification("", 0.0, "fallback", "空输入") + if re.sub(r"\s+", "", query) == _SCOPE_ERROR_MESSAGE: + return IntentClassification( + AGENT_INTENT_CASUAL_CHAT, + 1.0, + "rule", + "识别为系统提示文本而非业务查询", + ) if llm_client is not None: try: raw = await asyncio.wait_for( @@ -114,7 +122,7 @@ async def classify_advisor_intent( rule_intent = recognize_advisor_intent(query) if ( rule_intent == AGENT_INTENT_DATA_QUERY - and parsed.intent == AGENT_INTENT_RECOMMEND + and parsed.intent != AGENT_INTENT_DATA_QUERY and not any(term in (query or "") for term in _RECOMMENDATION_TERMS) ): return IntentClassification( diff --git a/agent/advisor_agent/intent/recognizer.py b/agent/advisor_agent/intent/recognizer.py index 9813958..99e0317 100644 --- a/agent/advisor_agent/intent/recognizer.py +++ b/agent/advisor_agent/intent/recognizer.py @@ -40,6 +40,8 @@ _REBALANCE_TERMS = ("调仓", "再平衡", "组合调整", "配置偏离", "偏 _FUND_ANALYSIS_TERMS = ("基金分析", "分析基金", "基金表现", "净值走势", "最大回撤", "夏普比率", "年化波动") _DIALOGUE_TERMS = ("话术", "沟通", "怎么跟客户说", "如何向客户解释", "安抚客户", "投诉处理") _RECOMMEND_TERMS = ("推荐", "组合建议", "配置建议", "买什么", "适合配置", "筛选基金", "投资方案") +_CUSTOMER_IDENTITY_TERMS = ("是谁", "姓名", "实名", "基本信息", "联系方式", "手机号") +_SCOPE_ERROR_MESSAGE = "投顾范围查询仅支持客户数据查询" def _contains_any(text: str, terms: tuple[str, ...]) -> bool: @@ -57,6 +59,8 @@ def recognize_advisor_intent(query: str | None, explicit_intent: str | None = No text = re.sub(r"\s+", "", query or "") if not text: return None + if text == _SCOPE_ERROR_MESSAGE: + return None # 先处理最明确的任务词,避免“查询基金收益”被误判成普通数据查询。 if _contains_any(text, _REBALANCE_TERMS): @@ -68,6 +72,13 @@ def recognize_advisor_intent(query: str | None, explicit_intent: str | None = No has_action = any(term in text for term in _QUERY_ACTIONS) has_data = any(term in text for term in _DATA_TERMS) + has_customer_identity = ( + "客户" in text + and _contains_any(text, _CUSTOMER_IDENTITY_TERMS) + and not _contains_any(text, _RECOMMEND_TERMS) + ) + if has_customer_identity: + return AGENT_INTENT_DATA_QUERY if has_data and (has_action or "客户" in text or "近一年" in text or "本月" in text): return AGENT_INTENT_DATA_QUERY if _contains_any(text, _RECOMMEND_TERMS): diff --git a/nl2sql/row_scope.py b/nl2sql/row_scope.py index fee7f47..cd04aaf 100644 --- a/nl2sql/row_scope.py +++ b/nl2sql/row_scope.py @@ -29,7 +29,7 @@ def apply_row_scope(sql: str, permission: dict, data_scope: dict | None) -> str: statement = parse_one(sql, read="mysql") except ParseError as exc: raise RowScopeError("SQL 解析失败") from exc - for table in statement.find_all(exp.Table): + for table in list(statement.find_all(exp.Table)): scope = scopes.get(table.name) if not scope: continue @@ -38,5 +38,6 @@ def apply_row_scope(sql: str, permission: dict, data_scope: dict | None) -> str: this=exp.column(scope["column"], table=table.alias_or_name), expressions=[exp.Literal.number(value) for value in values], ) - statement = statement.where(condition) + owner = table.find_ancestor(exp.Select) or statement + owner.where(condition, copy=False) return statement.sql(dialect="mysql") diff --git a/service/nl2sql/query_service.py b/service/nl2sql/query_service.py index 2693dfe..955f1b7 100644 --- a/service/nl2sql/query_service.py +++ b/service/nl2sql/query_service.py @@ -10,6 +10,7 @@ from nl2sql.executor import QueryExecutionError, execute_readonly_sql from nl2sql.row_scope import RowScopeError, apply_row_scope from nl2sql.result import build_chart_config, summarize_result from nl2sql.query_experience import apply_query_options, build_query_explanation +from nl2sql.query_rewriter import rewrite_query from nl2sql.supervisor import UnsupportedIntent, ensure_query_intent from nl2sql.sql_agent import generate_sql from nl2sql.sql_security import SqlSecurityError, validate_select_sql @@ -19,6 +20,29 @@ class QueryServiceError(RuntimeError): """查询编排失败或当前用户没有查询权限。""" +def _filter_schema_columns(schema: dict[str, Any], permission: dict[str, Any]) -> dict[str, Any]: + """只把当前用户有列权限的字段暴露给 SQL 生成模型。""" + authorized_columns = permission.get("columns") + if authorized_columns is None: + return schema + + filtered = dict(schema) + filtered["columns"] = [ + column + for column in schema.get("columns", []) + if column.get("field_name") + in set(authorized_columns.get(column.get("table_name"), set())) + ] + return filtered + + +def _normalize_advisor_placeholder(sql: str) -> str: + """统一 LLM 可能生成的投顾参数占位符,供 SQLAlchemy 命名绑定。""" + if "advisor_id" not in sql or "?" not in sql: + return sql + return sql.replace("?", ":advisor_id", 1) + + async def query( request: DataQueryRequest, *, @@ -30,8 +54,13 @@ async def query( conversation_context: str = "", ): """执行权限、召回、权威 Schema、生成和安全校验,返回可执行 SQL。""" + effective_question = await rewrite_query( + request.question, + conversation_context, + llm_client=llm_client, + ) try: - ensure_query_intent(request.question) + ensure_query_intent(effective_question) except UnsupportedIntent as exc: raise QueryServiceError(str(exc)) from exc permission = await permission_loader(request.user_id) @@ -40,7 +69,7 @@ async def query( if metadata_retriever is None or schema_loader is None: raise QueryServiceError("NL2SQL 查询依赖未配置") - hits = await metadata_retriever(request.question) + hits = await metadata_retriever(effective_question) candidate_tables = { hit.get("table_name") for hit in hits @@ -48,23 +77,27 @@ async def query( } if not candidate_tables: raise QueryServiceError("未找到有权限的业务表") - schema = await schema_loader(candidate_tables, permission) + schema = _filter_schema_columns( + await schema_loader(candidate_tables, permission), + permission, + ) if not schema.get("tables"): raise QueryServiceError("候选表未通过权威 Schema 校验") few_shot = [] if few_shot_retriever is not None: try: - few_shot = await few_shot_retriever(request.question) + few_shot = await few_shot_retriever(effective_question) except Exception: # noqa: BLE001 Few-shot 故障不阻断主查询 few_shot = [] generated = await generate_sql( - request.question, + effective_question, schema, llm_client=llm_client, few_shot=few_shot, conversation_context=conversation_context, ) + generated = replace(generated, sql=_normalize_advisor_placeholder(generated.sql)) max_rows = request.max_rows or permission.get("max_rows") or 1000 try: validated = validate_select_sql( -- 2.54.0 From 4f296eb58c0b7ea5b4cc52a200a685dc710b7aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E6=96=8C=E5=BC=BA?= Date: Mon, 14 Sep 2026 17:47:25 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=E4=BF=AE=E5=A4=8D=E5=AE=A2=E6=9C=8Dag?= =?UTF-8?q?ent=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/advisor_agent/data_query.py | 56 ++++- agent/advisor_agent/intent/generation_flow.py | 6 +- api/routers/advisor_agent.py | 207 +++++++++++------- frontend/.env.example | 2 - nl2sql/query_rewriter.py | 95 ++++++++ repositories/fin_transaction.py | 10 + schemas/advisor_agent.py | 3 + scripts/seed_advisor_nl2sql_permissions.py | 147 +++++++++++++ service/advisor/customers.py | 6 +- service/advisor_agent/customer_context.py | 115 ++++++++++ 10 files changed, 556 insertions(+), 91 deletions(-) delete mode 100644 frontend/.env.example create mode 100644 nl2sql/query_rewriter.py create mode 100644 scripts/seed_advisor_nl2sql_permissions.py create mode 100644 service/advisor_agent/customer_context.py diff --git a/agent/advisor_agent/data_query.py b/agent/advisor_agent/data_query.py index e94e7d6..f359831 100644 --- a/agent/advisor_agent/data_query.py +++ b/agent/advisor_agent/data_query.py @@ -24,12 +24,43 @@ from repositories.fin_holdings import FinHoldingsRepo from repositories.fin_product import FinProductRepo +def enrich_customer_rows( + rows: list[dict[str, Any]], + columns: list[str], + name_by_id: dict[int, str], +) -> dict[str, Any]: + """为查询结果补充客户姓名,并生成可直接展示的姓名摘要。""" + id_keys = ("customer_id", "客户ID", "客户编号", "客户_id") + enriched = [dict(row) for row in rows] + names: list[str] = [] + for row in enriched: + customer_id = next((row.get(key) for key in id_keys if row.get(key) is not None), None) + try: + customer_id = int(customer_id) + except (TypeError, ValueError): + continue + name = name_by_id.get(customer_id) + if name: + row["客户姓名"] = name + names.append(f"客户{customer_id}({name})") + output_columns = list(columns) + if any("客户姓名" in row for row in enriched) and "客户姓名" not in output_columns: + insert_at = next( + (index + 1 for index, column in enumerate(output_columns) if column in id_keys), + len(output_columns), + ) + output_columns.insert(insert_at, "客户姓名") + return {"rows": enriched, "columns": output_columns, "name_summary": "、".join(dict.fromkeys(names))} + + def _is_current_holdings_query(question: str) -> bool: text = "".join((question or "").split()) return any(term in text for term in ("当前持仓", "目前持仓", "现有持仓", "持仓明细", "持仓情况")) -async def _query_current_holdings(db, *, customer_id: int, trace_id: str) -> dict[str, Any]: +async def _query_current_holdings( + db, *, customer_id: int, trace_id: str, customer_name: str | None = None +) -> dict[str, Any]: holdings = await FinHoldingsRepo(db).list_by_customer(customer_id, status="持有中") product_repo = FinProductRepo(db) rows: list[dict[str, Any]] = [] @@ -60,7 +91,8 @@ async def _query_current_holdings(db, *, customer_id: int, trace_id: str) -> dic "truncated": False, "summary": f"当前持仓共 {len(rows)} 条记录。", "answer": ( - f"当前持有 {len(rows)} 只基金,总市值约 {total_value:.2f} 元。" + (f"客户{customer_id}({customer_name})" if customer_name else f"客户{customer_id}") + + f"当前持有 {len(rows)} 只基金,总市值约 {total_value:.2f} 元。" + (f"包括:{'、'.join(names[:6])}。" if names else "") ), "sql": None, @@ -108,11 +140,25 @@ async def execute_advisor_data_query( await ensure_customer_access(db, advisor_id=advisor_id, customer_id=customer_id) customer_ids = [customer_id] + name_by_id: dict[int, str] = {} + try: + relation_rows = await CustomerRelationRepo(db).list_customer_rows( + advisor_id=advisor_id, limit=max(len(customer_ids), 100) + ) + name_by_id = { + int(account.id): account.real_name + for _relation, account, _profile in relation_rows + if account.real_name + } + except Exception: # noqa: BLE001 姓名增强失败不阻断数据查询 + pass + if scope == "customer" and _is_current_holdings_query(question): return await _query_current_holdings( db, customer_id=customer_id, trace_id=trace_id, + customer_name=name_by_id.get(customer_id), ) permission = await load_query_permission(db, advisor_id) if not permission.get("can_query", False): @@ -168,6 +214,12 @@ async def execute_advisor_data_query( except (EmbeddingError, LLMFailError) as exc: raise QueryServiceError("投顾 Agent 依赖服务不可用,请检查 LLM/Embedding 服务连接") from exc payload = asdict(result) + enriched = enrich_customer_rows(payload.get("rows", []), payload.get("columns", []), name_by_id) + payload["rows"] = enriched["rows"] + payload["columns"] = enriched["columns"] + if enriched["name_summary"]: + existing_answer = payload.get("answer") or payload.get("summary") or "" + payload["answer"] = f"{existing_answer.rstrip('。')}。客户姓名:{enriched['name_summary']}。" payload["sql"] = None payload["customer_id"] = customer_id return payload diff --git a/agent/advisor_agent/intent/generation_flow.py b/agent/advisor_agent/intent/generation_flow.py index d8e82d6..7d2eb74 100644 --- a/agent/advisor_agent/intent/generation_flow.py +++ b/agent/advisor_agent/intent/generation_flow.py @@ -71,6 +71,7 @@ async def generate_recommendation_draft( memories: list[dict] | None = None, llm_client=None, llm_timeout: float = 5.0, + customer_context: dict | None = None, ): draft_data = build_recommendation_draft( customer_id=customer_id, @@ -87,7 +88,10 @@ async def generate_recommendation_draft( user_prompt=( "请根据以下候选基金和客户记忆生成简洁推荐说明:" + json.dumps( - {"candidates": candidates, "memories": memories or []}, + { + "candidates": candidates, + "customer_context": customer_context or {}, + }, ensure_ascii=False, ) ), diff --git a/api/routers/advisor_agent.py b/api/routers/advisor_agent.py index 04b105c..bd84d06 100644 --- a/api/routers/advisor_agent.py +++ b/api/routers/advisor_agent.py @@ -56,7 +56,9 @@ from service.advisor_agent.context import ( load_rebalance_context, load_recommendation_context, ) +from service.advisor_agent.customer_context import load_customer_context from service.event_publisher import publish_event +from nl2sql.session_context import SessionContextStore, build_conversation_context from schemas.advisor_agent import ( AdvisorDraftOperateReq, AdvisorDraftSaveReq, @@ -110,6 +112,16 @@ def _advisor_runtime(request: Request): return getattr(getattr(app, "state", None), "advisor_agent_runtime", None) +@router.get("/session/{session_id}/history") +async def advisor_session_history( + session_id: str, + user: SysUser = Depends(audited_advisor), +): + """读取当前投顾自己的短期 Agent 会话记录。""" + messages = await SessionContextStore(redis_db.client()).load(user.id, session_id) + return agent_success(messages, trace_id=new_request_id()) + + def _infer_chat_intent(query: str) -> str | None: """从自然语言问题推断投顾意图;无法确定时保留通用问答。""" if any(word in query for word in ("调仓", "再平衡", "组合偏离")): @@ -242,7 +254,8 @@ async def chat_stream( explicit_intent=None, ) inferred_intent = classification.intent - customer_id = None + scope = chat_request.scope + customer_id = chat_request.customer_id if not chat_request.query: code = ERR_CODE_FORBIDDEN_CUSTOMER message = "对话请求缺少有效参数" @@ -259,60 +272,30 @@ async def chat_stream( payload = None - # 单客户范围且未传编号时,兼容从问题中解析客户;投顾范围查询不解析客户。 - if inferred_intent in { - AGENT_INTENT_RECOMMEND, - AGENT_INTENT_REBALANCE, - AGENT_INTENT_FUND_ANALYSIS, - AGENT_INTENT_DIALOGUE_SCRIPT, - AGENT_INTENT_DATA_QUERY, - }: - customer_id, resolve_error = await _resolve_customer_from_query( + # 客户 ID 是可选上下文;未选择客户时尝试从自然语言识别姓名,失败不阻断 Agent 判断。 + if customer_id is not None: + await ensure_customer_access(db, advisor_id=user.id, customer_id=int(customer_id)) + else: + resolved_customer_id, _resolve_error = await _resolve_customer_from_query( db, advisor_id=user.id, query=chat_request.query ) - if resolve_error: - payload = agent_failure(ERR_CODE_FORBIDDEN_CUSTOMER, resolve_error, trace_id=trace_id) - async def resolve_error_events(): - yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_ERROR, **payload}, ensure_ascii=False)}\n\n" - return StreamingResponse(resolve_error_events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}) + if resolved_customer_id is not None: + customer_id = resolved_customer_id + scope = "customer" - # 不带客户编号时只提供通用基金问答,不读取客户画像,也不生成个性化草稿。 - if False: - payload = agent_failure( - ERR_CODE_FORBIDDEN_CUSTOMER, - "投顾范围查询仅支持客户数据查询", - trace_id=trace_id, - ) - elif False: - payload = agent_failure( - ERR_CODE_FORBIDDEN_CUSTOMER, - "投顾范围查询不能指定单个客户", - trace_id=trace_id, - ) - elif customer_id is None: - if inferred_intent in { - AGENT_INTENT_RECOMMEND, - AGENT_INTENT_REBALANCE, - AGENT_INTENT_FUND_ANALYSIS, - AGENT_INTENT_DIALOGUE_SCRIPT, - AGENT_INTENT_DATA_QUERY, - }: - if payload is None: - payload = agent_failure( - ERR_CODE_FORBIDDEN_CUSTOMER, - "个性化投顾分析需要在问题中明确客户编号或姓名", - trace_id=trace_id, - ) + if customer_id is None: + if inferred_intent == AGENT_INTENT_DATA_QUERY: + scope = "advisor" else: llm_client = getattr(runtime, "llm_client", None) if inferred_intent == AGENT_INTENT_CASUAL_CHAT: - answer = "您好,我是投顾助手,请选择客户后使用个性化分析。" + answer = "您好,我是投顾助手,可以协助您查询名下客户数据、分析基金和生成投顾辅助方案。" elif llm_client is None: - answer = "已收到问题。当前未配置通用投顾模型,请选择客户后使用个性化分析,或联系管理员配置 Agent 服务。" + answer = "已收到问题。当前未配置通用投顾模型,但您可以直接查询名下客户数据。" else: answer = await generate_text( llm_client, - system_prompt="你是基金投顾助手,只回答通用基金知识和产品分析问题,不读取或推断任何客户信息,不承诺收益,不代客交易。", + system_prompt="你是基金投顾助手。投顾未指定单个客户时,你可以回答通用问题或说明需要的客户范围;不得越权读取客户数据,不承诺收益,不代客交易。", user_prompt=chat_request.query, fallback=lambda: "当前模型暂时不可用,请稍后重试。", timeout=5.0, @@ -331,7 +314,42 @@ async def chat_stream( media_type="text/event-stream", headers={"X-Trace-Id": trace_id}, ) - else: + if inferred_intent == AGENT_INTENT_DATA_QUERY: + effective_scope = "customer" if customer_id is not None else "advisor" + try: + context_store = SessionContextStore(redis_db.client()) + conversation_context = build_conversation_context( + await context_store.load(user.id, chat_request.session_id) + ) + result = await execute_advisor_data_query( + db, + advisor_id=user.id, + customer_id=int(customer_id) if customer_id is not None else None, + scope=effective_scope, + question=chat_request.query, + trace_id=trace_id, + session_id=chat_request.session_id, + conversation_context=conversation_context, + llm_client=getattr(_advisor_runtime(request), "llm_client", None), + ) + except QueryServiceError as exc: + payload = agent_failure(ERR_CODE_LLM_ERROR, _data_query_error_message(exc), trace_id=trace_id) + else: + await context_store.append( + user.id, chat_request.session_id, chat_request.query, + result.get("answer") or result.get("summary") or "查询完成", + ) + + async def events(): + yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_META, 'intent': AGENT_INTENT_DATA_QUERY, 'query_id': result.get('query_id'), 'trace_id': trace_id}, ensure_ascii=False)}\n\n" + answer = result.get("answer") or result.get("summary") + if answer: + yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_TEXT, 'content': answer}, ensure_ascii=False)}\n\n" + yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_DONE, 'query_id': result.get('query_id')}, ensure_ascii=False)}\n\n" + + return StreamingResponse(events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}) + + if customer_id is not None: relation = await ensure_customer_access( db, advisor_id=user.id, customer_id=int(customer_id) ) @@ -345,6 +363,9 @@ async def chat_stream( context = await load_recommendation_context( db, customer_id=int(customer_id) ) + customer_context = await load_customer_context( + db, customer_id=int(customer_id), memories=memories + ) if context is not None: try: draft = await generate_recommendation_draft( @@ -354,6 +375,7 @@ async def chat_stream( trace_id=trace_id, memories=memories, llm_client=getattr(runtime, "llm_client", None), + customer_context=customer_context, **context, ) except Exception: @@ -377,43 +399,6 @@ async def chat_stream( ): yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" - return StreamingResponse( - events(), - media_type="text/event-stream", - headers={"X-Trace-Id": trace_id}, - ) - if inferred_intent == AGENT_INTENT_DATA_QUERY: - if not chat_request.query or not chat_request.query.strip(): - payload = agent_failure( - ERR_CODE_FORBIDDEN_CUSTOMER, - "查询问题不能为空", - trace_id=trace_id, - ) - else: - try: - result = await execute_advisor_data_query( - db, - advisor_id=user.id, - customer_id=int(customer_id) if customer_id is not None else None, - scope="customer", - question=chat_request.query, - trace_id=trace_id, - llm_client=getattr(_advisor_runtime(request), "llm_client", None), - ) - except QueryServiceError as exc: - payload = agent_failure( - ERR_CODE_LLM_ERROR, - _data_query_error_message(exc), - trace_id=trace_id, - ) - else: - async def events(): - yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_META, 'intent': AGENT_INTENT_DATA_QUERY, 'query_id': result.get('query_id'), 'trace_id': trace_id}, ensure_ascii=False)}\n\n" - answer = result.get("answer") or result.get("summary") - if answer: - yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_TEXT, 'content': answer}, ensure_ascii=False)}\n\n" - yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_DONE, 'query_id': result.get('query_id')}, ensure_ascii=False)}\n\n" - return StreamingResponse( events(), media_type="text/event-stream", @@ -432,6 +417,35 @@ async def chat_stream( result = build_fund_analysis( contexts[0]["fund"], contexts[0]["performance"] ) + runtime = _advisor_runtime(request) + if getattr(runtime, "llm_client", None) is not None: + customer_context = await load_customer_context( + db, + customer_id=int(customer_id), + memories=await _recall_advisor_memories( + request, + customer_id=int(customer_id), + query=chat_request.query, + ), + ) + result["analysis_text"] = await generate_text( + runtime.llm_client, + system_prompt=( + "你是合规的基金投顾分析助手。只能根据基金数据和客户上下文回答," + "不得承诺收益,不得代客交易;如果信息不足要明确说明。" + ), + user_prompt=json.dumps( + { + "question": chat_request.query, + "fund": contexts[0]["fund"], + "performance": contexts[0]["performance"], + "customer_context": customer_context, + }, + ensure_ascii=False, + ), + fallback=lambda: result["analysis_text"], + timeout=5.0, + ) async def events(): for event in ( @@ -456,6 +470,33 @@ async def chat_stream( else: scene_type = TALK_SCENE_PORTFOLIO_DIVERGENCE result = build_talk_script(scene_type) + runtime = _advisor_runtime(request) + if getattr(runtime, "llm_client", None) is not None: + memories = await _recall_advisor_memories( + request, + customer_id=int(customer_id), + query=chat_request.query, + ) + customer_context = await load_customer_context( + db, customer_id=int(customer_id), memories=memories + ) + result["content"] = await generate_text( + runtime.llm_client, + system_prompt=( + "你是华夏基金合规投顾助手。请生成简洁、克制、尊重客户的沟通参考话术," + "结合客户上下文但不要暴露内部字段,不承诺收益,不代客交易。" + ), + user_prompt=json.dumps( + { + "scene": scene_type, + "request": chat_request.query, + "customer_context": customer_context, + }, + ensure_ascii=False, + ), + fallback=lambda: result["content"], + timeout=5.0, + ) async def events(): for event in ( diff --git a/frontend/.env.example b/frontend/.env.example deleted file mode 100644 index 44711ac..0000000 --- a/frontend/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -NEXT_PUBLIC_API_BASE_URL=/backend -BACKEND_API_ORIGIN=http://127.0.0.1:8000 diff --git a/nl2sql/query_rewriter.py b/nl2sql/query_rewriter.py new file mode 100644 index 0000000..de031f8 --- /dev/null +++ b/nl2sql/query_rewriter.py @@ -0,0 +1,95 @@ +"""NL2SQL 查询问题改写与会话上下文补全。""" +from __future__ import annotations + +import logging +import re + +from tool.llm import llm + + +logger = logging.getLogger("nl2sql.query_rewriter") + +_SYSTEM_PROMPT = """你是基金平台 NL2SQL 的问题改写器。 +将当前问题改写为不依赖上下文、可直接交给数据库查询系统理解的完整中文问题。 +只输出改写后的问题,不要输出解释、JSON、SQL 或 Markdown。 +只补全指代和省略,不改变用户的查询目标、客户范围、时间范围或排序条件。 +不能扩大权限范围,不能臆造查询结果;上下文不足时保留原问题。 +""" + + +def _rewrite_explicit_customer_identity(question: str) -> str | None: + normalized = re.sub(r"\s+", "", question) + match = re.fullmatch( + r"(?:查询|请问|告诉我)?客户(?:编号|号|#)?(\d+)(是谁|姓名是什么|姓名|基本信息|联系方式|手机号是什么|手机号)", + normalized, + ) + if not match: + return None + return f"查询客户编号{match.group(1)}的姓名" + + +def _rewrite_customer_name_follow_up(question: str, context: str) -> str | None: + """从最近的客户身份问答中恢复客户编号,保证追问可独立查询。""" + normalized_question = re.sub(r"\s+", "", question) + if not any(term in normalized_question for term in ("持仓", "资产", "基金", "产品", "收益", "盈亏", "余额", "交易")): + return None + + lines = context.splitlines() + for index, line in enumerate(lines): + customer_match = re.search(r"客户(?:编号|号|#)?(\d+)", line) + if not customer_match or not any(term in line for term in ("是谁", "姓名")): + continue + for answer_line in lines[index + 1 : index + 4]: + if not answer_line.startswith("assistant:"): + continue + answer = answer_line.split(":", 1)[1].strip() + name_match = re.search(r"(?:姓名|是)\s*[::]?\s*([\u4e00-\u9fff]{2,6})", answer) + candidates = [name_match.group(1)] if name_match else re.findall(r"[\u4e00-\u9fff]{2,6}", answer) + for name in reversed(candidates): + if name in normalized_question: + return f"查询客户编号{customer_match.group(1)}的持仓和资产信息" + return None + + +async def rewrite_query( + question: str, + conversation_context: str = "", + *, + llm_client=llm, +) -> str: + """使用有限会话上下文补全问题;改写失败时安全返回原问题。""" + original = (question or "").strip() + context = (conversation_context or "").strip() + if not original: + return original + explicit_identity = _rewrite_explicit_customer_identity(original) + if explicit_identity: + return explicit_identity + contextual_identity = _rewrite_customer_name_follow_up(original, context) + if contextual_identity: + return contextual_identity + if not context or llm_client is None: + return original + + prompt = ( + f"会话上下文:\n{context[:4000]}\n\n" + f"当前问题:\n{original[:2000]}\n\n" + "改写后的独立问题:" + ) + try: + rewritten = await llm_client.chat( + [ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ], + temperature=0, + max_tokens=512, + ) + except Exception: # noqa: BLE001 改写失败不阻断原查询 + logger.warning("NL2SQL 查询问题改写失败,继续使用原问题", exc_info=True) + return original + value = str(rewritten or "").strip() + return value or original + + +__all__ = ["rewrite_query"] diff --git a/repositories/fin_transaction.py b/repositories/fin_transaction.py index 488f0c8..3212c24 100644 --- a/repositories/fin_transaction.py +++ b/repositories/fin_transaction.py @@ -36,3 +36,13 @@ class FinTransactionRepo(BaseRepository): FinTransaction.customer_id == customer_id ) ) + + async def list_recent(self, customer_id: int, *, limit: int = 20) -> list[FinTransaction]: + """按客户读取最近成交记录,供投顾上下文摘要使用。""" + stmt = ( + select(FinTransaction) + .where(FinTransaction.customer_id == customer_id) + .order_by(FinTransaction.create_time.desc(), FinTransaction.id.desc()) + .limit(max(1, min(limit, 100))) + ) + return list((await self.db.scalars(stmt)).all()) diff --git a/schemas/advisor_agent.py b/schemas/advisor_agent.py index b41f488..f426a6d 100644 --- a/schemas/advisor_agent.py +++ b/schemas/advisor_agent.py @@ -22,6 +22,9 @@ class AdvisorRebalanceRunReq(BaseModel): class AdvisorChatReq(BaseModel): query: str = Field(min_length=1, max_length=4000) + session_id: str | None = Field(default=None, max_length=64) + scope: Literal["customer", "advisor"] = "advisor" + customer_id: int | None = Field(default=None, gt=0) class AdvisorDataQueryReq(BaseModel): diff --git a/scripts/seed_advisor_nl2sql_permissions.py b/scripts/seed_advisor_nl2sql_permissions.py new file mode 100644 index 0000000..8a263c9 --- /dev/null +++ b/scripts/seed_advisor_nl2sql_permissions.py @@ -0,0 +1,147 @@ +"""为投顾角色初始化 NL2SQL 最小只读权限。""" +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from sqlalchemy import text + +from config import database +from config.database.mysql import get_session_factory + + +ROLE = { + "role_code": "advisor", + "role_name": "投顾", + "employee_role": "投顾", + "can_query": 1, + "max_rows": 1000, + "daily_quota": 100, + "status": "active", +} + +TABLE_PERMISSIONS = [ + ("fin_holdings", "customer_ids", "customer_id"), + ("fin_transaction", "customer_ids", "customer_id"), + ("fin_customer_profile", "customer_ids", "customer_id"), + ("customer_relation", "customer_ids", "customer_id"), + ("sys_user", "customer_ids", "id"), + ("fin_product", "none", None), + ("fund_performance", "none", None), +] + +COLUMNS = { + "fin_holdings": ( + "customer_id", "product_id", "shares", "cost_amount", "current_value", + "profit_loss", "profit_ratio", "status", "update_time", + ), + "fin_transaction": ( + "customer_id", "product_id", "transaction_type", "amount", "shares", + "nav", "fee", "status", "create_time", + ), + "fin_customer_profile": ( + "customer_id", "risk_level", "risk_score", "investment_experience", + "annual_income_range", "total_assets", "customer_level", "update_time", + ), + "customer_relation": ( + "customer_id", "advisor_id", "assign_time", "signed_time", "status", + ), + "sys_user": ( + "id", "real_name", "phone", "customer_level", "status", + ), + "fin_product": ( + "id", "product_code", "product_name", "product_type", "risk_level", + "expected_return", "nav", "nav_date", "fee_rate", "term_days", + "fund_manager", "status", + ), + "fund_performance": ( + "product_id", "period", "return_rate", "annual_volatility", + "max_drawdown", "sharpe", "calc_date", + ), +} + + +async def main() -> None: + async with get_session_factory()() as db: + await db.execute( + text( + """ + INSERT INTO nl2sql_query_role + (role_code, role_name, employee_role, can_query, max_rows, daily_quota, status) + VALUES + (:role_code, :role_name, :employee_role, :can_query, :max_rows, :daily_quota, :status) + ON DUPLICATE KEY UPDATE + role_name = VALUES(role_name), + can_query = VALUES(can_query), + max_rows = VALUES(max_rows), + daily_quota = VALUES(daily_quota), + status = VALUES(status) + """ + ), + ROLE, + ) + role_id = int( + await db.scalar( + text("SELECT id FROM nl2sql_query_role WHERE employee_role = :employee_role"), + {"employee_role": ROLE["employee_role"]}, + ) + ) + + await db.execute( + text("DELETE FROM nl2sql_role_table_permission WHERE role_id = :role_id"), + {"role_id": role_id}, + ) + await db.execute( + text("DELETE FROM nl2sql_role_column_permission WHERE role_id = :role_id"), + {"role_id": role_id}, + ) + + for table_name, scope_type, scope_column in TABLE_PERMISSIONS: + await db.execute( + text( + """ + INSERT INTO nl2sql_role_table_permission + (role_id, table_name, permission, row_scope_type, row_scope_column, status) + VALUES + (:role_id, :table_name, 'SELECT', :row_scope_type, :row_scope_column, 'active') + """ + ), + { + "role_id": role_id, + "table_name": table_name, + "row_scope_type": scope_type, + "row_scope_column": scope_column, + }, + ) + + for table_name, columns in COLUMNS.items(): + for column_name in columns: + access_mode = "mask" if table_name == "sys_user" and column_name == "phone" else "allow" + await db.execute( + text( + """ + INSERT INTO nl2sql_role_column_permission + (role_id, table_name, column_name, access_mode, mask_type, status) + VALUES + (:role_id, :table_name, :column_name, :access_mode, :mask_type, 'active') + """ + ), + { + "role_id": role_id, + "table_name": table_name, + "column_name": column_name, + "access_mode": access_mode, + "mask_type": "partial" if access_mode == "mask" else None, + }, + ) + + await db.commit() + print({"role_id": role_id, "tables": len(TABLE_PERMISSIONS), "columns": sum(map(len, COLUMNS.values()))}) + await database.mysql.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/service/advisor/customers.py b/service/advisor/customers.py index cbda198..a81328d 100644 --- a/service/advisor/customers.py +++ b/service/advisor/customers.py @@ -25,7 +25,7 @@ from repositories.risk_assessment import CustomerProfileRepo from repositories.sys_user import SysUserRepo from schemas.advisor import RelationReq from service.advisor.audit_writer import write_audit -from service.advisor.masking import mask_name, mask_phone +from service.advisor.masking import mask_phone from service.advisor.permissions import ensure_customer_owned, require_owned_relation from utils.exceptions import ForbiddenError, NotFoundError from utils.pagination import normalize_pagination, pagination_result @@ -72,7 +72,7 @@ async def list_customers( items.append( { "customer_id": rel.customer_id, - "real_name": mask_name(account.real_name), + "real_name": account.real_name or "", "phone": mask_phone(account.phone), "risk_level": profile.risk_level if profile else None, "customer_level": account.customer_level, @@ -107,7 +107,7 @@ async def get_customer( return { "customer_id": customer_id, - "real_name": mask_name(account.real_name), + "real_name": account.real_name or "", "phone": phone, "risk_level": profile.risk_level if profile else None, "risk_score": profile.risk_score if profile else None, diff --git a/service/advisor_agent/customer_context.py b/service/advisor_agent/customer_context.py new file mode 100644 index 0000000..0f48dba --- /dev/null +++ b/service/advisor_agent/customer_context.py @@ -0,0 +1,115 @@ +"""为投顾 Agent 组装经过裁剪的客户综合上下文。""" +from __future__ import annotations + +import logging +from decimal import Decimal + +from repositories.fin_customer_profile import FinCustomerProfileRepo +from repositories.fin_holdings import FinHoldingsRepo +from repositories.fin_transaction import FinTransactionRepo + +MAX_HOLDINGS = 50 +MAX_TRANSACTIONS = 20 +MAX_MEMORIES = 10 +logger = logging.getLogger("advisor.customer_context") + + +def _number(value): + if isinstance(value, Decimal): + return float(value) + return value + + +def _profile_payload(profile) -> dict: + if profile is None: + return {} + return { + "risk_level": getattr(profile, "risk_level", None), + "risk_score": getattr(profile, "risk_score", None), + "investment_experience": getattr(profile, "investment_experience", None), + "annual_income_range": getattr(profile, "annual_income_range", None), + "total_assets": _number(getattr(profile, "total_assets", None)), + "asset_allocation": getattr(profile, "asset_allocation", None), + "product_preference": getattr(profile, "product_preference", None), + "customer_level": getattr(profile, "customer_level", None), + } + + +def _holding_payload(holding, product) -> dict: + return { + "product_id": holding.product_id, + "product_code": getattr(product, "product_code", None), + "product_name": getattr(product, "product_name", None), + "shares": _number(holding.shares), + "cost_amount": _number(holding.cost_amount), + "current_value": _number(holding.current_value), + "profit_loss": _number(holding.profit_loss), + "profit_ratio": _number(holding.profit_ratio), + "status": holding.status, + } + + +def _transaction_payload(transaction) -> dict: + return { + "product_id": transaction.product_id, + "transaction_type": transaction.transaction_type, + "amount": _number(transaction.amount), + "shares": _number(transaction.shares), + "nav": _number(transaction.nav), + "status": transaction.status, + "create_time": transaction.create_time.isoformat() + if hasattr(transaction.create_time, "isoformat") + else str(transaction.create_time), + } + + +def _memory_payload(memory) -> dict: + return { + "tag": memory.get("tag"), + "content": str(memory.get("content") or "")[:500], + "info_type": memory.get("info_type"), + "memory_type": memory.get("memory_type"), + } + + +async def load_customer_context( + db, + *, + customer_id: int, + memories: list[dict] | None = None, + profile_repo_cls=FinCustomerProfileRepo, + holdings_repo_cls=FinHoldingsRepo, + transaction_repo_cls=FinTransactionRepo, +) -> dict: + """读取并裁剪客户画像、持仓、交易和长期记忆。""" + try: + profile = await profile_repo_cls(db).get_by_customer_id(customer_id) + except Exception: # noqa: BLE001 个性化上下文故障不阻断主流程 + logger.warning("customer profile context unavailable", exc_info=True) + profile = None + try: + holding_rows = await holdings_repo_cls(db).list_with_products( + customer_id, include_closed=False + ) + except Exception: # noqa: BLE001 个性化上下文故障不阻断主流程 + logger.warning("customer holdings context unavailable", exc_info=True) + holding_rows = [] + try: + transactions = await transaction_repo_cls(db).list_recent( + customer_id, limit=MAX_TRANSACTIONS + ) + except Exception: # noqa: BLE001 个性化上下文故障不阻断主流程 + logger.warning("customer transaction context unavailable", exc_info=True) + transactions = [] + return { + "customer_id": customer_id, + "profile": _profile_payload(profile), + "holdings": [ + _holding_payload(holding, product) + for holding, product in holding_rows[:MAX_HOLDINGS] + ], + "transactions": [ + _transaction_payload(item) for item in transactions[:MAX_TRANSACTIONS] + ], + "memories": [_memory_payload(item) for item in (memories or [])[:MAX_MEMORIES]], + } -- 2.54.0 From 6d44b8ff37681eadeced8d64f99ee90c1c887ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E6=96=8C=E5=BC=BA?= Date: Mon, 14 Sep 2026 17:56:43 +0800 Subject: [PATCH 3/3] remove frontend directory from git tracking --- frontend/AGENTS.md | 9 - frontend/CLAUDE.md | 1 - frontend/README.md | 34 - frontend/app/account/adjust/page.tsx | 12 - frontend/app/account/page.tsx | 23 - frontend/app/admin/advisor/page.tsx | 2 - frontend/app/admin/advisor/tools/page.tsx | 26 - frontend/app/admin/common/page.tsx | 2 - frontend/app/admin/risk/page.tsx | 2 - frontend/app/globals.css | 48 - frontend/app/layout.tsx | 18 - frontend/app/login/page.tsx | 27 - frontend/app/page.tsx | 48 - frontend/app/products/[code]/page.tsx | 40 - frontend/app/products/page.tsx | 33 - frontend/app/register/page.tsx | 14 - frontend/app/risk-assessment/page.tsx | 16 - frontend/app/tools/knowledge/page.tsx | 16 - frontend/app/tools/nl2sql/page.tsx | 53 - frontend/components/agent-chat.tsx | 85 - frontend/components/agent-mount.tsx | 30 - frontend/components/public-header.tsx | 29 - frontend/components/role-workspaces.tsx | 46 - frontend/lib/api.ts | 60 - frontend/lib/auth.ts | 40 - frontend/next-env.d.ts | 7 - frontend/next.config.ts | 14 - frontend/package-lock.json | 1798 --------------------- frontend/package.json | 25 - frontend/postcss.config.mjs | 7 - frontend/public/hero-finance.jpg | Bin 208159 -> 0 bytes frontend/tsconfig.json | 41 - frontend/tsconfig.lint.json | 8 - frontend/types/api.ts | 34 - 34 files changed, 2648 deletions(-) delete mode 100644 frontend/AGENTS.md delete mode 100644 frontend/CLAUDE.md delete mode 100644 frontend/README.md delete mode 100644 frontend/app/account/adjust/page.tsx delete mode 100644 frontend/app/account/page.tsx delete mode 100644 frontend/app/admin/advisor/page.tsx delete mode 100644 frontend/app/admin/advisor/tools/page.tsx delete mode 100644 frontend/app/admin/common/page.tsx delete mode 100644 frontend/app/admin/risk/page.tsx delete mode 100644 frontend/app/globals.css delete mode 100644 frontend/app/layout.tsx delete mode 100644 frontend/app/login/page.tsx delete mode 100644 frontend/app/page.tsx delete mode 100644 frontend/app/products/[code]/page.tsx delete mode 100644 frontend/app/products/page.tsx delete mode 100644 frontend/app/register/page.tsx delete mode 100644 frontend/app/risk-assessment/page.tsx delete mode 100644 frontend/app/tools/knowledge/page.tsx delete mode 100644 frontend/app/tools/nl2sql/page.tsx delete mode 100644 frontend/components/agent-chat.tsx delete mode 100644 frontend/components/agent-mount.tsx delete mode 100644 frontend/components/public-header.tsx delete mode 100644 frontend/components/role-workspaces.tsx delete mode 100644 frontend/lib/api.ts delete mode 100644 frontend/lib/auth.ts delete mode 100644 frontend/next-env.d.ts delete mode 100644 frontend/next.config.ts delete mode 100644 frontend/package-lock.json delete mode 100644 frontend/package.json delete mode 100644 frontend/postcss.config.mjs delete mode 100644 frontend/public/hero-finance.jpg delete mode 100644 frontend/tsconfig.json delete mode 100644 frontend/tsconfig.lint.json delete mode 100644 frontend/types/api.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md deleted file mode 100644 index 643577d..0000000 --- a/frontend/AGENTS.md +++ /dev/null @@ -1,9 +0,0 @@ - - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. - -This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. - - diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md deleted file mode 100644 index 43c994c..0000000 --- a/frontend/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 7d8d1ee..0000000 --- a/frontend/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# 华夏基金 PC 前端 - -独立的用户端官网与员工后台前端工程。 - -## 当前状态 - -已建立 Next.js + React + TypeScript + Tailwind CSS + Lucide 的基础工程和页面路由占位。 - -## 启动 - -```bash -npm install -npm run dev -``` - -默认访问地址:`http://localhost:3000` - -## 配置 - -复制 `.env.example` 为 `.env.local`,根据实际后端地址调整: - -```env -NEXT_PUBLIC_API_BASE_URL=/backend -BACKEND_API_ORIGIN=http://127.0.0.1:8000 - -前端默认通过 Next.js 同源代理访问后端,避免浏览器 CORS 预检失败。 -``` - -## 边界 - -- 只开发 PC 前端。 -- 不修改项目根目录下现有 FastAPI 后端。 -- 基金数据通过后端 API 获取,不直接连接 MySQL。 -- 角色显示由前端映射为“投顾专员”“风控专员”“员工通用”。 diff --git a/frontend/app/account/adjust/page.tsx b/frontend/app/account/adjust/page.tsx deleted file mode 100644 index 969946e..0000000 --- a/frontend/app/account/adjust/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; -import { FormEvent, useState } from "react"; -import { apiFetch } from "@/lib/api"; - -export default function AccountAdjustPage() { - const [direction, setDirection] = useState<"add" | "sub">("add"); const [amount, setAmount] = useState(""); const [message, setMessage] = useState(""); - async function submit(event: FormEvent) { event.preventDefault(); try { await apiFetch("/account/adjust", { method: "POST", body: JSON.stringify({ direction, amount }) }); setMessage("余额调整已提交"); setAmount(""); } catch (error) { setMessage(error instanceof Error ? error.message : "余额调整失败"); } } - return
返回用户中心

账户余额调整

充值或提现将由后端进行权限与金额校验。

{message &&

{message}

}
; -} diff --git a/frontend/app/account/page.tsx b/frontend/app/account/page.tsx deleted file mode 100644 index 1b7e6c5..0000000 --- a/frontend/app/account/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { ArrowLeft, ArrowUpRight, CircleUserRound, Landmark, RefreshCw, ShieldCheck, WalletCards, X } from "lucide-react"; -import { FormEvent, useEffect, useState } from "react"; -import { apiFetch } from "@/lib/api"; -import { clearToken } from "@/lib/auth"; -import type { UserPayload } from "@/types/api"; - -interface Balance { balance?: number | string; available_balance?: number | string; } -interface Holding { product_id?: number; product_name?: string; product_code?: string; shares?: number | string; current_value?: number | string; market_value?: number | string; profit_loss?: number | string; } - -export default function AccountPage() { - const [user, setUser] = useState(null); const [balance, setBalance] = useState(null); const [holdings, setHoldings] = useState([]); const [state, setState] = useState<"loading" | "ready" | "error">("loading"); const [action, setAction] = useState<"purchase" | "redeem" | null>(null); - async function loadAccount() { setState("loading"); try { const [me, account, holdingList] = await Promise.all([apiFetch<{ user: UserPayload }>("/auth/me"), apiFetch("/account/balance"), apiFetch("/holdings")]); setUser(me.user); setBalance(account); setHoldings(holdingList); setState("ready"); } catch { setState("error"); } } - useEffect(() => { void loadAccount(); }, []); - function signOut() { clearToken(); window.location.href = "/login"; } - return
华华夏基金
基金产品
返回首页

Personal Account

用户中心

{user?.real_name ?? user?.username ?? "您的华夏基金账户"}

{state === "loading" && }{state === "error" && }{state === "ready" && <>

账户余额

¥{balance?.balance ?? "--"}

可用余额

¥{balance?.available_balance ?? "--"}

风险测评

待完成测评

当前后端仅提供提交接口

我的持仓

{holdings.length ?
{holdings.map((item) =>

{item.product_name ?? "基金产品"}

{item.product_code}

¥{item.current_value ?? item.market_value ?? "--"}

份额 {item.shares ?? "--"} · 盈亏 {item.profit_loss ?? "--"}

)}
:

暂无持仓数据

}

快捷操作

交易记录

当前后端暂未提供交易记录查询接口,页面不会展示虚构数据。

消息通知

当前后端暂未提供用户消息查询接口。

}
{action && setAction(null)} onDone={() => { setAction(null); void loadAccount(); }} />}
; -} - -function TradeModal({ type, holdings, onClose, onDone }: { type: "purchase" | "redeem"; holdings: Holding[]; onClose: () => void; onDone: () => void }) { const [productId, setProductId] = useState(String(holdings[0]?.product_id ?? "")); const [value, setValue] = useState(""); const [message, setMessage] = useState(""); const [loading, setLoading] = useState(false); async function submit(event: FormEvent) { event.preventDefault(); setLoading(true); setMessage(""); try { await apiFetch(type === "purchase" ? "/purchase" : "/redeem", { method: "POST", body: JSON.stringify(type === "purchase" ? { product_id: Number(productId), amount: value } : { product_id: Number(productId), shares: value }) }); onDone(); } catch (error) { setMessage(error instanceof Error ? error.message : "操作失败,请稍后重试"); } finally { setLoading(false); } } return

{type === "purchase" ? "申购基金" : "赎回基金"}

{message &&

{message}

}
; } - -function Notice({ text }: { text: string }) { return
{text}
; } diff --git a/frontend/app/admin/advisor/page.tsx b/frontend/app/admin/advisor/page.tsx deleted file mode 100644 index 200eb60..0000000 --- a/frontend/app/admin/advisor/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { AdvisorWorkspace } from "@/components/role-workspaces"; -export default function AdvisorPage() { return ; } diff --git a/frontend/app/admin/advisor/tools/page.tsx b/frontend/app/admin/advisor/tools/page.tsx deleted file mode 100644 index 5a58f8f..0000000 --- a/frontend/app/admin/advisor/tools/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { ArrowLeft, Download, RefreshCw, Save, Send, Trash2 } from "lucide-react"; -import { FormEvent, useEffect, useState } from "react"; -import { apiFetch } from "@/lib/api"; - -interface Customer { customer_id?: number; id?: number; real_name?: string; name?: string; } -interface Draft { draft_id?: string; id?: string; title?: string; content?: string; status?: string; customer_id?: number; } -interface Visit { id: number; customer_id: number; visit_type?: string; visit_time?: string; summary?: string; content?: string; } - -export default function AdvisorToolsPage() { - const [customers, setCustomers] = useState([]); const [customerId, setCustomerId] = useState(""); const [section, setSection] = useState("detail"); const [data, setData] = useState(null); const [message, setMessage] = useState(""); - const [drafts, setDrafts] = useState([]); const [draftId, setDraftId] = useState(""); const [draftTitle, setDraftTitle] = useState(""); const [draftContent, setDraftContent] = useState(""); - const [visits, setVisits] = useState([]); const [visitId, setVisitId] = useState(null); const [visitType, setVisitType] = useState("电话回访"); const [visitTime, setVisitTime] = useState(""); const [visitSummary, setVisitSummary] = useState(""); - useEffect(() => { void apiFetch<{ items?: Customer[] }>("/advisor/customers?page=1&page_size=100").then((result) => { setCustomers(result.items ?? []); const first = result.items?.[0]; if (first) setCustomerId(String(first.customer_id ?? first.id)); }).catch(() => setMessage("客户列表加载失败")); }, []); - async function load() { setMessage(""); try { const id = Number(customerId); const paths: Record = { detail: `/advisor/customers/${id}`, holdings: `/advisor/customers/${id}/holdings`, reports: `/advisor/customers/${id}/reports`, diagnosis: `/advisor/diagnosis/${id}`, strategies: "/advisor/strategies", funds: "/advisor/funds", audit: "/advisor/audit/ledger" }; if (section === "drafts") { const result = await apiFetch<{ items?: Draft[] }>(`/advisor/drafts?page=1&page_size=50${customerId ? `&customer_id=${id}` : ""}`); setDrafts(result.items ?? []); setData(result); return; } if (section === "visits") { const result = await apiFetch<{ items?: Visit[] }>(`/advisor/visits?page=1&page_size=50${customerId ? `&customer_id=${id}` : ""}`); setVisits(result.items ?? []); setData(result); return; } setData(await apiFetch(paths[section])); } catch (error) { setMessage(error instanceof Error ? error.message : "加载失败"); } } - function chooseDraft(draft: Draft) { const id = String(draft.draft_id ?? draft.id ?? ""); setDraftId(id); setDraftTitle(draft.title ?? ""); setDraftContent(draft.content ?? ""); } - async function draftAction(action: "save" | "discard" | "send") { if (!draftId) return; try { if (action === "save") await apiFetch(`/advisor/drafts/${draftId}/save`, { method: "PUT", body: JSON.stringify({ title: draftTitle, content: draftContent }) }); else await apiFetch(`/advisor/drafts/${draftId}/${action}`, { method: "POST" }); setMessage(action === "save" ? "草稿已保存" : action === "discard" ? "草稿已废弃" : "草稿已发送"); await load(); } catch (error) { setMessage(error instanceof Error ? error.message : "草稿操作失败"); } } - async function saveVisit(event: FormEvent) { event.preventDefault(); try { const body = { customer_id: Number(customerId), visit_type: visitType, visit_time: new Date(visitTime).toISOString(), summary: visitSummary }; if (visitId) await apiFetch(`/advisor/visits/${visitId}`, { method: "PUT", body: JSON.stringify(body) }); else await apiFetch("/advisor/visits", { method: "POST", body: JSON.stringify(body) }); setMessage(visitId ? "回访已更新" : "回访已新增"); setVisitId(null); setVisitSummary(""); await load(); } catch (error) { setMessage(error instanceof Error ? error.message : "回访保存失败"); } } - return
返回投顾工作区

投顾业务工具

{section === "audit" && }
{message &&

{message}

} - {section === "drafts" &&

草稿列表

{drafts.map((draft) => )}{!drafts.length &&

暂无草稿

}
setDraftTitle(event.target.value)} placeholder="草稿标题" className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm" />