diff --git a/b/bz_order/getbz_order.dspy b/b/bz_order/getbz_order.dspy index 9d9ab0f..77626ad 100644 --- a/b/bz_order/getbz_order.dspy +++ b/b/bz_order/getbz_order.dspy @@ -79,6 +79,9 @@ async def getbz_order(ns={}): # ns['total_discount_amount'] = total_discount_amount # ns['total_count'] = total_count[0]['total_count'] if total_count else 0 + # 排除大模型订单 + sql += " AND og.is_big_model = 0" + count_sql += " AND og.is_big_model = 0" # 根据订单号搜索 if ns.get('id'): diff --git a/b/cntoai/chat.html b/b/cntoai/chat.html new file mode 100644 index 0000000..9dae92e --- /dev/null +++ b/b/cntoai/chat.html @@ -0,0 +1,727 @@ + + + + + + 模型对话测试 · cntoai + + + +
+ + +
+
+ 测试说明 + 左侧可手动填写 api_urlapi_keyuserid 直接联调;未填 userid 时持久化接口会失败。Dspy 网关默认 https://dev.opencomputing.cn,路径为 /cntoai/*.dspy。须已执行 chat_tables.sql。 +
+ +
+
+ + +
+
+ + +
+ + +
+ +
+
+ +
+ + +
+
+ + Ctrl+Enter 发送 +
+ +
+
+
+
+ + + + diff --git a/b/cntoai/chat_send.dspy b/b/cntoai/chat_send.dspy new file mode 100644 index 0000000..60059c1 --- /dev/null +++ b/b/cntoai/chat_send.dspy @@ -0,0 +1,177 @@ +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + + +def _parse_bool(value, default=True): + if value is None or value == '': + return default + if isinstance(value, bool): + return value + return str(value).lower() in ('1', 'true', 'yes', 'on') + + +def _title_from_message(ns): + text = ns.get('message') or ns.get('text') or '' + text = str(text).strip().replace('\n', ' ') + if not text: + return '新对话' + return text[:30] + ('...' if len(text) > 30 else '') + + +def _build_user_content(ns): + text_parts = [] + if ns.get('message'): + text_parts.append(str(ns.get('message'))) + if ns.get('text'): + text_parts.append(str(ns.get('text'))) + if ns.get('document_text'): + text_parts.append(str(ns.get('document_text'))) + + parts = [] + merged_text = '\n'.join([p for p in text_parts if p]).strip() + if merged_text: + parts.append({'type': 'text', 'text': merged_text}) + if ns.get('image_url'): + parts.append({'type': 'image_url', 'image_url': {'url': ns.get('image_url')}}) + if ns.get('image_base64'): + mime = ns.get('image_mime') or 'image/jpeg' + b64 = ns.get('image_base64') + if not str(b64).startswith('data:'): + b64 = 'data:%s;base64,%s' % (mime, b64) + parts.append({'type': 'image_url', 'image_url': {'url': b64}}) + if ns.get('document_url'): + parts.append({'type': 'file', 'file': {'file_url': ns.get('document_url')}}) + if not parts: + return '' + if len(parts) == 1 and parts[0]['type'] == 'text': + return parts[0]['text'] + return parts + + +async def _load_session_messages(sor, session_id): + sql = """ + SELECT role, content, content_type + FROM chat_message + WHERE session_id = '%s' + ORDER BY created_at ASC; + """ % _escape(session_id) + rows = await sor.sqlExe(sql, {}) + messages = [] + for row in rows: + content = row.get('content') or '' + if row.get('content_type') == 'mixed': + import json + try: + content = json.loads(content) + except Exception: + pass + messages.append({'role': row['role'], 'content': content}) + return messages + + +async def chat_send(ns={}): + """ + 发送消息并保存多轮对话(需先执行 chat_tables.sql)。 + + 参数:model, message, stream(默认true), session_id, + image_url, image_base64, document_url, document_text, + with_chunks(true时返回上游 SSE 分片列表,便于确认流式) + + 说明:本接口(chat_send.dspy)为 JSON 一次性返回。 + 需要浏览器端实时流式请调用 chat_send_stream.dspy(SSE)。 + """ + import json + import traceback + + # model = ns.get('model') + model = 'deepseek-v4-pro' + if not model: + return {'status': False, 'msg': 'model is required'} + + userid = ns.get('userid') or await get_user() + if not userid: + return {'status': False, 'msg': '未找到用户'} + + user_content = _build_user_content(ns) + if not user_content: + return {'status': False, 'msg': '请输入文本,或提供图片/文档参数'} + + content_type = 'mixed' if isinstance(user_content, list) else 'text' + store_content = json.dumps(user_content, ensure_ascii=False) if content_type == 'mixed' else str(user_content) + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + session_id = ns.get('session_id') + if not session_id: + session_id = uuid() + await sor.C('chat_session', { + 'id': session_id, + 'userid': userid, + 'model': model, + 'title': _title_from_message(ns), + }) + else: + sessions = await sor.R('chat_session', {'id': session_id, 'userid': userid}) + if not sessions: + return {'status': False, 'msg': '会话不存在'} + + await sor.C('chat_message', { + 'id': uuid(), + 'session_id': session_id, + 'role': 'user', + 'content': store_content, + 'content_type': content_type, + }) + + history = await _load_session_messages(sor, session_id) + stream_val = _parse_bool(ns.get('stream'), True) + chat_result = await path_call('llm_chat_completions.dspy', { + 'model': model, + 'messages': history, + 'stream': stream_val, + 'userid': userid, + 'api_url': ns.get('api_url'), + 'api_key': ns.get('api_key'), + 'model_id': ns.get('model_id'), + 'with_chunks': ns.get('with_chunks', True), + }) + if not chat_result.get('status'): + return chat_result + + reply = chat_result['data']['reply'] + chunks = chat_result['data'].get('chunks') or [] + chunk_count = chat_result['data'].get('chunk_count', 0) + await sor.C('chat_message', { + 'id': uuid(), + 'session_id': session_id, + 'role': 'assistant', + 'content': reply, + 'content_type': 'text', + }) + await sor.sqlExe( + "UPDATE chat_session SET updated_at = NOW() WHERE id = '%s';" + % _escape(session_id), + {}, + ) + + return { + 'status': True, + 'msg': 'send success', + 'data': { + 'session_id': session_id, + 'reply': reply, + 'model': model, + 'stream': stream_val, + 'chunk_count': chunk_count, + 'chunks': chunks if ns.get('with_chunks', True) else None, + }, + } + except Exception: + return {'status': False, 'msg': 'send failed, %s' % traceback.format_exc()} + + +ret = await chat_send(params_kw) +return ret diff --git a/b/cntoai/chat_send_stream.dspy b/b/cntoai/chat_send_stream.dspy new file mode 100644 index 0000000..b98d116 --- /dev/null +++ b/b/cntoai/chat_send_stream.dspy @@ -0,0 +1,311 @@ +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + + +def _parse_bool(value, default=True): + if value is None or value == '': + return default + if isinstance(value, bool): + return value + return str(value).lower() in ('1', 'true', 'yes', 'on') + + +def _title_from_message(ns): + text = ns.get('message') or ns.get('text') or '' + text = str(text).strip().replace('\n', ' ') + if not text: + return '新对话' + return text[:30] + ('...' if len(text) > 30 else '') + + +def _build_user_content(ns): + text_parts = [] + if ns.get('message'): + text_parts.append(str(ns.get('message'))) + if ns.get('text'): + text_parts.append(str(ns.get('text'))) + if ns.get('document_text'): + text_parts.append(str(ns.get('document_text'))) + + parts = [] + merged_text = '\n'.join([p for p in text_parts if p]).strip() + if merged_text: + parts.append({'type': 'text', 'text': merged_text}) + if ns.get('image_url'): + parts.append({'type': 'image_url', 'image_url': {'url': ns.get('image_url')}}) + if ns.get('image_base64'): + mime = ns.get('image_mime') or 'image/jpeg' + b64 = ns.get('image_base64') + if not str(b64).startswith('data:'): + b64 = 'data:%s;base64,%s' % (mime, b64) + parts.append({'type': 'image_url', 'image_url': {'url': b64}}) + if ns.get('document_url'): + parts.append({'type': 'file', 'file': {'file_url': ns.get('document_url')}}) + if not parts: + return '' + if len(parts) == 1 and parts[0]['type'] == 'text': + return parts[0]['text'] + return parts + + +async def _load_session_messages(sor, session_id): + sql = """ + SELECT role, content, content_type + FROM chat_message + WHERE session_id = '%s' + ORDER BY created_at ASC; + """ % _escape(session_id) + rows = await sor.sqlExe(sql, {}) + messages = [] + for row in rows: + content = row.get('content') or '' + if row.get('content_type') == 'mixed': + import json + try: + content = json.loads(content) + except Exception: + pass + messages.append({'role': row['role'], 'content': content}) + return messages + + +async def _resolve_chat_config(ns, sor): + # api_url = ns.get('api_url') + # api_key = ns.get('api_key') + api_url = 'https://api.deepseek.com/chat/completions' + api_key = 'sk-c22d6573e85a4d3fa8ab932386cf2909' + if not api_url and ns.get('model_id'): + doc_rows = await sor.sqlExe( + "SELECT api_url FROM model_api_doc WHERE model_id = '%s' LIMIT 1;" + % _escape(ns.get('model_id')), + {}, + ) + if doc_rows and doc_rows[0].get('api_url'): + api_url = doc_rows[0]['api_url'] + if not str(api_url).endswith('/chat/completions'): + api_url = str(api_url).rstrip('/') + '/chat/completions' + if not api_url: + param_rows = await sor.R('params', {'pname': 'cntoai_llm_chat_url'}) + if param_rows: + api_url = param_rows[0]['pvalue'] + else: + domain_rows = await sor.R('params', {'pname': 'cntoai_domain'}) + if domain_rows: + api_url = domain_rows[0]['pvalue'].rstrip('/') + '/llmage/v1/chat/completions' + else: + api_url = 'https://ai.atvoe.com/llmage/v1/chat/completions' + if not api_key: + userid = ns.get('userid') or await get_user() + if userid: + action = ns.get('apikey_action') or 'user_self_create' + keys = await sor.R('user_api_keys', {'userid': userid, 'action': action}) + if not keys: + keys = await sor.R('user_api_keys', {'userid': userid, 'action': 'sync'}) + if keys: + api_key = keys[0].get('opc_apikey') + if not api_key: + key_rows = await sor.R('params', {'pname': 'cntoai_llm_api_key'}) + if key_rows: + api_key = key_rows[0]['pvalue'] + return api_url, api_key + + +def _extract_stream_piece(payload): + choice = (payload.get('choices') or [{}])[0] + delta = choice.get('delta') or {} + message = choice.get('message') or {} + piece = ( + delta.get('content') + or delta.get('reasoning_content') + or message.get('content') + or choice.get('text') + or payload.get('content') + or '' + ) + if piece is None: + return '' + return str(piece) + + +def _sse_event(obj): + import json + return 'data: %s\n\n' % json.dumps(obj, ensure_ascii=False) + + +async def _iter_upstream_stream(api_url, api_key, payload): + import aiohttp + import json + + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer %s' % api_key, + } + payload = dict(payload) + payload['stream'] = True + + buffer = '' + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=600)) as session: + async with session.post(api_url, headers=headers, json=payload) as response: + if response.status != 200: + err_text = await response.text() + yield {'type': 'error', 'msg': 'HTTP %s: %s' % (response.status, err_text[:500])} + return + + async for raw in response.content: + buffer += raw.decode('utf-8', errors='ignore') + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() + if not line or line.startswith(':') or not line.startswith('data:'): + continue + data = line[5:].strip() + if data == '[DONE]': + return + try: + payload_obj = json.loads(data) + piece = _extract_stream_piece(payload_obj) + if piece: + yield {'type': 'content', 'content': piece} + except Exception: + continue + + tail = buffer.strip() + if tail: + try: + body = json.loads(tail) + choice = (body.get('choices') or [{}])[0] + msg = choice.get('message') or {} + piece = msg.get('content') or choice.get('text') or '' + if piece: + yield {'type': 'content', 'content': str(piece)} + except Exception: + pass + + +async def inference_generator(request, params_kw=None, **kw): + """ + 流式 chat_send:先存 user 消息,SSE 推送 assistant 片段,结束后存库。 + + SSE 事件: + {"type":"meta","session_id":"...","model":"..."} + {"type":"content","content":"片段"} + {"type":"done","session_id":"...","reply":"完整文本","model":"..."} + {"type":"error","msg":"..."} + """ + import json + import traceback + + ns = params_kw or {} + # model = ns.get('model') + model = 'deepseek-v4-pro' + if not model: + yield _sse_event({'type': 'error', 'msg': 'model is required'}) + yield 'data: [DONE]\n\n' + return + + userid = ns.get('userid') or await get_user() + if not userid: + yield _sse_event({'type': 'error', 'msg': '未找到用户'}) + yield 'data: [DONE]\n\n' + return + + user_content = _build_user_content(ns) + if not user_content: + yield _sse_event({'type': 'error', 'msg': '请输入文本,或提供图片/文档参数'}) + yield 'data: [DONE]\n\n' + return + + content_type = 'mixed' if isinstance(user_content, list) else 'text' + store_content = json.dumps(user_content, ensure_ascii=False) if content_type == 'mixed' else str(user_content) + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + session_id = ns.get('session_id') + if not session_id: + session_id = uuid() + await sor.C('chat_session', { + 'id': session_id, + 'userid': userid, + 'model': model, + 'title': _title_from_message(ns), + }) + else: + sessions = await sor.R('chat_session', {'id': session_id, 'userid': userid}) + if not sessions: + yield _sse_event({'type': 'error', 'msg': '会话不存在'}) + yield 'data: [DONE]\n\n' + return + + await sor.C('chat_message', { + 'id': uuid(), + 'session_id': session_id, + 'role': 'user', + 'content': store_content, + 'content_type': content_type, + }) + + history = await _load_session_messages(sor, session_id) + api_url, api_key = await _resolve_chat_config(ns, sor) + if not api_key: + yield _sse_event({'type': 'error', 'msg': '未找到 API Key'}) + yield 'data: [DONE]\n\n' + return + + yield _sse_event({ + 'type': 'meta', + 'session_id': session_id, + 'model': model, + 'stream': True, + }) + + parts = [] + async for evt in _iter_upstream_stream(api_url, api_key, { + 'model': model, + 'messages': history, + }): + if evt.get('type') == 'error': + yield _sse_event(evt) + yield 'data: [DONE]\n\n' + return + if evt.get('type') == 'content': + parts.append(evt['content']) + yield _sse_event(evt) + + reply = ''.join(parts) + await sor.C('chat_message', { + 'id': uuid(), + 'session_id': session_id, + 'role': 'assistant', + 'content': reply, + 'content_type': 'text', + }) + await sor.sqlExe( + "UPDATE chat_session SET updated_at = NOW() WHERE id = '%s';" + % _escape(session_id), + {}, + ) + + yield _sse_event({ + 'type': 'done', + 'session_id': session_id, + 'reply': reply, + 'model': model, + }) + yield 'data: [DONE]\n\n' + except Exception: + yield _sse_event({'type': 'error', 'msg': traceback.format_exc()}) + yield 'data: [DONE]\n\n' + + +async def inference(request, *args, params_kw=None, **kw): + from functools import partial + env = request._run_ns.copy() + f = partial(inference_generator, request, params_kw=params_kw, **kw) + return await env.stream_response(request, f, content_type='text/event-stream') + + +ret = await inference(request, params_kw=params_kw) +return ret diff --git a/b/cntoai/chat_session_delete.dspy b/b/cntoai/chat_session_delete.dspy new file mode 100644 index 0000000..bb12134 --- /dev/null +++ b/b/cntoai/chat_session_delete.dspy @@ -0,0 +1,39 @@ +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + + +async def chat_session_delete(ns={}): + """删除会话及其全部消息""" + session_id = ns.get('session_id') + if not session_id: + return {'status': False, 'msg': 'session_id is required'} + + userid = ns.get('userid') or await get_user() + if not userid: + return {'status': False, 'msg': '未找到用户'} + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + sessions = await sor.R('chat_session', {'id': session_id, 'userid': userid}) + if not sessions: + return {'status': False, 'msg': '会话不存在'} + + await sor.sqlExe( + "DELETE FROM chat_message WHERE session_id = '%s';" % _escape(session_id), + {}, + ) + await sor.sqlExe( + "DELETE FROM chat_session WHERE id = '%s' AND userid = '%s';" + % (_escape(session_id), _escape(userid)), + {}, + ) + return {'status': True, 'msg': 'delete success'} + except Exception as e: + return {'status': False, 'msg': 'delete failed, %s' % str(e)} + + +ret = await chat_session_delete(params_kw) +return ret diff --git a/b/cntoai/chat_session_list.dspy b/b/cntoai/chat_session_list.dspy new file mode 100644 index 0000000..e4bddfc --- /dev/null +++ b/b/cntoai/chat_session_list.dspy @@ -0,0 +1,50 @@ +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + + +async def chat_session_list(ns={}): + """当前用户的对话会话列表(左侧栏历史)""" + userid = ns.get('userid') or await get_user() + if not userid: + return {'status': False, 'msg': '未找到用户'} + + page_size = int(ns.get('page_size')) if ns.get('page_size') else 100 + current_page = int(ns.get('current_page')) if ns.get('current_page') else 1 + offset = (current_page - 1) * page_size + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + count_sql = """ + SELECT COUNT(*) AS total_count FROM chat_session + WHERE userid = '%s'; + """ % _escape(userid) + total = (await sor.sqlExe(count_sql, {}))[0]['total_count'] + + find_sql = """ + SELECT id, model, title, created_at, updated_at + FROM chat_session + WHERE userid = '%s' + ORDER BY updated_at DESC + LIMIT %s OFFSET %s; + """ % (_escape(userid), page_size, offset) + sessions = await sor.sqlExe(find_sql, {}) + + return { + 'status': True, + 'msg': 'list success', + 'data': { + 'total_count': total, + 'page_size': page_size, + 'current_page': current_page, + 'sessions': sessions, + }, + } + except Exception as e: + return {'status': False, 'msg': 'list failed, %s' % str(e)} + + +ret = await chat_session_list(params_kw) +return ret diff --git a/b/cntoai/chat_session_messages.dspy b/b/cntoai/chat_session_messages.dspy new file mode 100644 index 0000000..d702f8d --- /dev/null +++ b/b/cntoai/chat_session_messages.dspy @@ -0,0 +1,66 @@ +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + + +async def chat_session_messages(ns={}): + """获取某次会话的全部消息""" + session_id = ns.get('session_id') + if not session_id: + return {'status': False, 'msg': 'session_id is required'} + + userid = ns.get('userid') or await get_user() + if not userid: + return {'status': False, 'msg': '未找到用户'} + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + sessions = await sor.R('chat_session', {'id': session_id, 'userid': userid}) + if not sessions: + return {'status': False, 'msg': '会话不存在'} + + sql = """ + SELECT id, role, content, content_type, created_at + FROM chat_message + WHERE session_id = '%s' + ORDER BY created_at ASC; + """ % _escape(session_id) + rows = await sor.sqlExe(sql, {}) + + messages = [] + for row in rows: + content = row.get('content') or '' + if row.get('content_type') == 'mixed': + import json + try: + content = json.loads(content) + except Exception: + pass + if isinstance(content, list): + text_parts = [p.get('text', '') for p in content if p.get('type') == 'text'] + display = '\n'.join([t for t in text_parts if t]) or '[多媒体消息]' + else: + display = content + messages.append({ + 'id': row['id'], + 'role': row['role'], + 'content': display, + 'created_at': row.get('created_at'), + }) + + return { + 'status': True, + 'msg': 'get messages success', + 'data': { + 'session': sessions[0], + 'messages': messages, + }, + } + except Exception as e: + return {'status': False, 'msg': 'get messages failed, %s' % str(e)} + + +ret = await chat_session_messages(params_kw) +return ret diff --git a/b/cntoai/chat_tables.sql b/b/cntoai/chat_tables.sql new file mode 100644 index 0000000..f84777e --- /dev/null +++ b/b/cntoai/chat_tables.sql @@ -0,0 +1,23 @@ +-- 多轮对话:请先执行本脚本创建表后再使用 chat_send / chat_session_* 接口 + +CREATE TABLE IF NOT EXISTS `chat_session` ( + `id` varchar(64) NOT NULL COMMENT '会话ID', + `userid` varchar(64) NOT NULL COMMENT '用户ID', + `model` varchar(128) NOT NULL COMMENT '模型名称', + `title` varchar(255) DEFAULT NULL COMMENT '会话标题(首条问题摘要)', + `created_at` datetime DEFAULT current_timestamp() COMMENT '创建时间', + `updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp() COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_userid_updated` (`userid`, `updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='模型对话会话'; + +CREATE TABLE IF NOT EXISTS `chat_message` ( + `id` varchar(64) NOT NULL COMMENT '消息ID', + `session_id` varchar(64) NOT NULL COMMENT '会话ID', + `role` varchar(32) NOT NULL COMMENT '角色: user / assistant / system', + `content` mediumtext COMMENT '消息内容(纯文本或JSON)', + `content_type` varchar(32) DEFAULT 'text' COMMENT 'text / mixed', + `created_at` datetime DEFAULT current_timestamp() COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_session_id` (`session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='模型对话消息'; diff --git a/b/cntoai/create_model_apikey.dspy b/b/cntoai/create_model_apikey.dspy new file mode 100644 index 0000000..9969df8 --- /dev/null +++ b/b/cntoai/create_model_apikey.dspy @@ -0,0 +1,104 @@ +async def create_model_apikey(ns={}): + import aiohttp + + if not ns.get('userid'): + ns['userid'] = await get_user() + + if not ns.get('userid'): + return { + 'status': False, + 'msg': '未找到用户' + } + + # 通过userid从user_api_keys表中查询opc_apikey + db = DBPools() + async with db.sqlorContext('kboss') as sor: + records = await sor.R('user_api_keys', {'userid': ns['userid'], 'action': 'sync'}) + if not records: + return { + 'status': False, + 'msg': '未找到用户opc_apikey' + } + + already_sync_user_key = records[0]['opc_apikey'] + already_sync_user_appid = records[0]['appid'] + + # domain 从数据库params表中获取到pname=cntoai_domain的pvalue值 + db = DBPools() + async with db.sqlorContext('kboss') as sor: + domain = await sor.R('params', {'pname': 'cntoai_domain'}) + if domain: + domain = domain[0]['pvalue'] + else: + debug(f"create_model_apikey未找到域名") + return { + 'status': False, + 'msg': '未找到域名' + } + + # 目标URL + url = f"{domain}/dapi/apply_apikey.dspy" + + # 请求头 + headers = { + "Content-Type": "application/json", + "Authorization": "Bearer %s" % already_sync_user_key + } + + # 请求体数据 + payload = { + "appname": ns.get('appname'), + "description": ns.get('description'), + } + + # 正常返回的是 {'status': 'ok', 'data': {'id': 'HlEQmcbCA1dX0qjhffA_K', 'name': 'cn_ai_user', 'description': '', 'secretkey': 'QUZVcXg5V1p1STMybG5Ia4r9NHBpkeRw558aATmohvZ7GYptvg==', 'allowedips': None, 'orgid': 'KHtWKY2LENTU4hYYim1Ks'}} + try: + # 创建一个异步会话 + result_sysnc = None + async with aiohttp.ClientSession() as session: + # 发送POST请求 + async with session.post(url, headers=headers, data=json.dumps(payload)) as response: + # 打印响应状态码 + debug(f"create_model_apikey状态码: {response.status}") + debug(f"create_model_apikey响应: {await response.text()}") + result_sysnc = await response.json() + + if not result_sysnc.get('status') == 'ok': + debug(f"create_model_apikey创建模型apikey失败: {result_sysnc}") + return { + 'status': False, + 'msg': f"创建模型apikey失败: {result_sysnc}" + } + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + # user_api_keys表格 userid/opc_apikey + # 首先判断apikey是否存在 + remote_table_id = result_sysnc['data'].get('id') + name = result_sysnc['data'].get('name') + secretkey = result_sysnc['data'].get('secretkey') + apikey = result_sysnc['data'].get('apikey') + + await sor.C('user_api_keys', { + 'userid': ns['userid'], + 'remote_table_id': remote_table_id, + 'name': name, + 'opc_apikey': apikey, + 'secretkey': secretkey, + 'action': 'user_self_create', + }) + return { + 'status': True, + 'msg': '创建模型apikey成功' + } + + except Exception as e: + debug(f"sync_cn_ai_user{userid}同步用户失败: {e}") + return { + 'status': False, + 'msg': f"sync_cn_ai_user{userid}同步用户失败: {e}" + } + + +ret = await create_model_apikey(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/get_deerer_header.dspy b/b/cntoai/get_deerer_header.dspy new file mode 100644 index 0000000..ab7fb6a --- /dev/null +++ b/b/cntoai/get_deerer_header.dspy @@ -0,0 +1,38 @@ +async def get_deerer_header(ns={}): + from appPublic.aes import aes_decode_b64, aes_encode_b64 + if not ns.get('userid'): + userid = await get_user() + else: + userid = ns.get('userid') + if not userid: + return { + 'status': False, + 'msg': '请传递用户ID' + } + db = DBPools() + async with db.sqlorContext('kboss') as sor: + records = await sor.R('user_api_keys', {'userid': userid, 'action': 'sync'}) + if not records: + return { + 'status': False, + 'msg': '未找到匹配的用户' + } + apikey = records[0]['opc_apikey'] + appid = records[0]['appid'] + sk = records[0]['secretkey'] + if not apikey or not appid or not sk: + return { + 'status': False, + 'msg': '没有找到匹配的用户' + } + tim = time.time() + txt = f'{tim}:{apikey}' + cyber = aes_encode_b64(sk, txt) + return { + 'status': True, + 'data': f'Deerer {appid}-:-{cyber}' + } + + +ret = await get_deerer_header(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/get_model_api_doc.dspy b/b/cntoai/get_model_api_doc.dspy new file mode 100644 index 0000000..b0a1ebd --- /dev/null +++ b/b/cntoai/get_model_api_doc.dspy @@ -0,0 +1,53 @@ +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + + +async def get_model_api_doc(ns={}): + """ + 根据 model_id 查询模型 API 文档。 + + 参数: + model_id (str) 模型ID,必填 + + 返回 data 字段: + id, model_id, curl_code, python_code, created_at, updated_at + """ + model_id = ns.get('id') + if not model_id: + return {'status': False, 'msg': 'model id is required'} + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + # 通过model_id从model_management表中查询model_name + model_name_sql = """ + SELECT model_name FROM model_management WHERE id = '%s' LIMIT 1; + """ % _escape(model_id) + model_name = await sor.sqlExe(model_name_sql, {}) + if not model_name: + return {'status': False, 'msg': 'model not found'} + model_name = model_name[0]['model_name'] + + find_sql = """ + SELECT id, api_url, model_id, curl_code, python_code, created_at, updated_at + FROM model_api_doc + WHERE model_id = '%s' + LIMIT 1; + """ % _escape(model_id) + result = await sor.sqlExe(find_sql, {}) + if not result: + return {'status': False, 'msg': 'api doc not found'} + result[0]['model_name'] = model_name + return { + 'status': True, + 'msg': 'get model api doc success', + 'data': result[0], + } + except Exception as e: + return {'status': False, 'msg': 'get model api doc failed, %s' % str(e)} + + +ret = await get_model_api_doc(params_kw) +return ret diff --git a/b/cntoai/get_model_apikey.dspy b/b/cntoai/get_model_apikey.dspy new file mode 100644 index 0000000..9dda40e --- /dev/null +++ b/b/cntoai/get_model_apikey.dspy @@ -0,0 +1,109 @@ +async def get_model_apikey(ns={}): + import aiohttp + + if not ns.get('userid'): + ns['userid'] = await get_user() + + if not ns.get('userid'): + return { + 'status': False, + 'msg': '未找到用户' + } + + action = ns.get('action') + if not action: + action = 'user_self_create' + + # 通过userid从user_api_keys表中查询opc_apikey + db = DBPools() + async with db.sqlorContext('kboss') as sor: + records = await sor.R('user_api_keys', {'userid': ns['userid'], 'action': action}) + if not records: + return { + 'status': False, + 'msg': 'apikey不存在' + } + + return { + 'status': True, + 'msg': '获取模型apikey成功', + 'data': records + } + # already_sync_user_key = records[0]['opc_apikey'] + # already_sync_user_appid = records[0]['appid'] + + # # domain 从数据库params表中获取到pname=cntoai_domain的pvalue值 + # db = DBPools() + # async with db.sqlorContext('kboss') as sor: + # domain = await sor.R('params', {'pname': 'cntoai_domain'}) + # if domain: + # domain = domain[0]['pvalue'] + # else: + # debug(f"get_model_apikey未找到域名") + # return { + # 'status': False, + # 'msg': '未找到域名' + # } + + # # 目标URL + # url = f"{domain}/dapi/downapps.dspy" + + # # 请求头 + # headers = { + # "Content-Type": "application/json", + # "Authorization": "Bearer %s" % already_sync_user_key + # } + + # try: + # # 创建一个异步会话 + # result_sysnc = None + # async with aiohttp.ClientSession() as session: + # # 发送GET请求 + # async with session.get(url, headers=headers) as response: + # # 打印响应状态码 + # debug(f"get_model_apikey状态码: {response.status}") + # result_sysnc = await response.json() + + # if not result_sysnc.get('status') == 'ok': + # debug(f"get_model_apikey获取模型apikey失败: {result_sysnc}") + # return { + # 'status': False, + # 'msg': f"获取模型apikey失败: {result_sysnc}" + # } + + # db = DBPools() + # async with db.sqlorContext('kboss') as sor: + # # user_api_keys表格 userid/opc_apikey + # # 首先判断apikey是否存在 + # apikeys = result_sysnc['data']['apikeys'] + # # 遍历apikeys,如果apikey不存在,则创建, 如果存在则做更新 根据userid和remote_table_id判断 + # for apikey_item in apikeys: + # remote_table_id = apikey_item.get('id') + # name = '' if not apikey_item.get('name') else apikey_item.get('name') + # apikeyid = apikey_item.get('apikeyid') + # exist_record = await sor.R('user_api_keys', {'userid': ns['userid'], 'remote_table_id': remote_table_id}) + # if exist_record: + # update_sql = f"UPDATE user_api_keys SET name = '{name}', opc_apikey = '{apikeyid}' WHERE userid = '{ns['userid']}' AND remote_table_id = '{remote_table_id}'" + # await sor.sqlExe(update_sql, {}) + # else: + # await sor.C('user_api_keys', { + # 'userid': ns['userid'], + # 'remote_table_id': remote_table_id, + # 'name': name, + # 'opc_apikey': apikeyid, + # 'action': 'user_self_create', + # }) + + # result_sysnc['status'] = True + # return result_sysnc + + # except Exception as e: + # debug(f"get_model_apikey获取模型apikey失败: {e}") + # return { + # 'status': False, + # 'msg': f"get_model_apikey获取模型apikey失败: {e}" + # } + + +ret = await get_model_apikey(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/get_user_balance.dspy b/b/cntoai/get_user_balance.dspy new file mode 100644 index 0000000..53c6ce8 --- /dev/null +++ b/b/cntoai/get_user_balance.dspy @@ -0,0 +1,45 @@ +async def get_user_balance(ns={}): + """ + 根据 userid 查询对应机构的客户余额。 + + :param userid: 用户 ID + :return: 账户余额(与 getCustomerBalance 返回值一致) + """ + debug(ns) + # apikey = ns.get('apikey') + userid = ns.get('userid') + db = DBPools() + async with db.sqlorContext('kboss') as sor: + # if not apikey: + # return { + # 'status': 'error', + # 'msg': 'apikey is required' + # } + # userid_li = await sor.R('user_api_keys', {'opc_apikey': apikey}) + # if not userid_li: + # return { + # 'status': 'error', + # 'msg': 'apikey无效,请联系管理员' + # } + # userid = userid_li[0]['userid'] + if not userid: + return { + 'status': 'error', + 'msg': 'userid is required' + } + user = await sor.R('users', {'id': userid}) + if not user: + return { + 'status': 'error', + 'msg': '用户不存在' + } + orgid = await sor.R('organization', {'id': user[0]['orgid']}) + balance = await getCustomerBalance(sor, orgid[0]['id']) + return { + 'status': 'ok', + 'balance': balance + } + + +ret = await get_user_balance(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/llm_chat_completions.dspy b/b/cntoai/llm_chat_completions.dspy new file mode 100644 index 0000000..980be9a --- /dev/null +++ b/b/cntoai/llm_chat_completions.dspy @@ -0,0 +1,283 @@ +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + + +def _parse_bool(value, default=True): + if value is None or value == '': + return default + if isinstance(value, bool): + return value + return str(value).lower() in ('1', 'true', 'yes', 'on') + + +def _parse_messages(ns): + """解析历史消息:支持 list 或 JSON 字符串""" + raw = ns.get('messages') + if not raw: + return [] + if isinstance(raw, list): + return raw + if isinstance(raw, str): + import json + try: + return json.loads(raw) + except Exception: + return [] + return [] + + +def build_user_content(ns): + """ + 构建单条 user 消息的 content,支持文本 / 图片 / 文档链接。 + + 参数(可组合): + message / text 文本 + image_url 图片 URL + image_base64 图片 base64(不含 data: 前缀) + document_url 文档 URL(以 file 类型传给兼容接口) + document_text 文档纯文本(拼入 text) + """ + text_parts = [] + if ns.get('message'): + text_parts.append(str(ns.get('message'))) + if ns.get('text'): + text_parts.append(str(ns.get('text'))) + if ns.get('document_text'): + text_parts.append(str(ns.get('document_text'))) + + parts = [] + merged_text = '\n'.join([p for p in text_parts if p]).strip() + if merged_text: + parts.append({'type': 'text', 'text': merged_text}) + + if ns.get('image_url'): + parts.append({ + 'type': 'image_url', + 'image_url': {'url': ns.get('image_url')}, + }) + if ns.get('image_base64'): + mime = ns.get('image_mime') or 'image/jpeg' + b64 = ns.get('image_base64') + if not str(b64).startswith('data:'): + b64 = 'data:%s;base64,%s' % (mime, b64) + parts.append({ + 'type': 'image_url', + 'image_url': {'url': b64}, + }) + if ns.get('document_url'): + parts.append({ + 'type': 'file', + 'file': {'file_url': ns.get('document_url')}, + }) + + if not parts: + return '' + if len(parts) == 1 and parts[0]['type'] == 'text': + return parts[0]['text'] + return parts + + +async def _resolve_chat_config(ns, sor): + """解析 API 地址与 Bearer Token""" + + api_url = 'https://api.deepseek.com/chat/completions' + api_key = 'sk-c22d6573e85a4d3fa8ab932386cf2909' + + # api_url = ns.get('api_url') + # api_key = ns.get('api_key') + + if not api_url and ns.get('model_id'): + doc_rows = await sor.sqlExe( + "SELECT api_url FROM model_api_doc WHERE model_id = '%s' LIMIT 1;" + % _escape(ns.get('model_id')), + {}, + ) + if doc_rows and doc_rows[0].get('api_url'): + api_url = doc_rows[0]['api_url'] + if not str(api_url).endswith('/chat/completions'): + api_url = str(api_url).rstrip('/') + '/chat/completions' + + if not api_url: + param_rows = await sor.R('params', {'pname': 'cntoai_llm_chat_url'}) + if param_rows: + api_url = param_rows[0]['pvalue'] + else: + domain_rows = await sor.R('params', {'pname': 'cntoai_domain'}) + if domain_rows: + api_url = domain_rows[0]['pvalue'].rstrip('/') + '/llmage/v1/chat/completions' + else: + api_url = 'https://ai.atvoe.com/llmage/v1/chat/completions' + + if not api_key: + userid = ns.get('userid') or await get_user() + if userid: + action = ns.get('apikey_action') or 'user_self_create' + keys = await sor.R('user_api_keys', {'userid': userid, 'action': action}) + if not keys: + keys = await sor.R('user_api_keys', {'userid': userid, 'action': 'sync'}) + if keys: + api_key = keys[0].get('opc_apikey') + if not api_key: + key_rows = await sor.R('params', {'pname': 'cntoai_llm_api_key'}) + if key_rows: + api_key = key_rows[0]['pvalue'] + + return api_url, api_key + + +def _extract_stream_piece(payload): + """从 SSE chunk 中提取文本(兼容 OpenAI / Qwen 等格式)""" + choice = (payload.get('choices') or [{}])[0] + delta = choice.get('delta') or {} + message = choice.get('message') or {} + piece = ( + delta.get('content') + or delta.get('reasoning_content') + or message.get('content') + or choice.get('text') + or payload.get('content') + or '' + ) + if piece is None: + return '' + return str(piece) + + +async def _read_stream_response(response): + """解析 SSE 流式响应;若上游未按 SSE 返回则回退解析整段 JSON""" + import json + chunks = [] + buffer = '' + async for raw in response.content: + buffer += raw.decode('utf-8', errors='ignore') + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() + if not line or line.startswith(':'): + continue + if not line.startswith('data:'): + continue + data = line[5:].strip() + if data == '[DONE]': + return ''.join(chunks), chunks + try: + payload = json.loads(data) + piece = _extract_stream_piece(payload) + if piece: + chunks.append(piece) + except Exception: + continue + + reply = ''.join(chunks) + if reply: + return reply, chunks + + # 上游可能忽略 stream=true,直接返回完整 JSON + tail = buffer.strip() + if tail: + try: + body = json.loads(tail) + choice = (body.get('choices') or [{}])[0] + msg = choice.get('message') or {} + reply = msg.get('content') or choice.get('text') or '' + if reply: + return str(reply), [str(reply)] + except Exception: + pass + return reply, chunks + + +async def llm_chat_completions(ns={}): + """ + OpenAI 兼容 chat/completions(aiohttp)。 + + 参数: + model (str) 模型名,必填 + message / text 当前用户文本 + messages 历史消息 JSON 数组或 list,多轮对话 + stream (bool) 是否流式,默认 True + image_url / image_base64 图片 + document_url / document_text 文档 + api_url / api_key 可覆盖默认配置 + model_id 从 model_api_doc 读取 api_url + userid 用于查 user_api_keys + """ + import aiohttp + import json + import traceback + + model = ns.get('model') + if not model: + return {'status': False, 'msg': 'model is required'} + + stream = _parse_bool(ns.get('stream'), True) + history = _parse_messages(ns) + user_content = build_user_content(ns) + if not user_content and not history: + return {'status': False, 'msg': 'message is required'} + + messages = list(history) + if user_content: + messages.append({'role': 'user', 'content': user_content}) + + payload = { + 'model': model, + 'stream': stream, + 'messages': messages, + } + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + api_url, api_key = await _resolve_chat_config(ns, sor) + if not api_key: + return {'status': False, 'msg': '未找到 API Key,请先创建或配置 cntoai_llm_api_key'} + + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer %s' % api_key, + } + + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=600), + ) as session: + async with session.post(api_url, headers=headers, json=payload) as response: + if response.status != 200: + err_text = await response.text() + return { + 'status': False, + 'msg': '模型请求失败 HTTP %s: %s' % (response.status, err_text[:500]), + } + + stream_chunks = [] + if stream: + reply, stream_chunks = await _read_stream_response(response) + usage = {} + else: + body = await response.json() + choice = (body.get('choices') or [{}])[0] + msg = choice.get('message') or {} + reply = msg.get('content') or '' + usage = body.get('usage') or {} + + return { + 'status': True, + 'msg': 'chat success', + 'data': { + 'model': model, + 'reply': reply, + 'messages': messages + [{'role': 'assistant', 'content': reply}], + 'usage': usage, + 'stream': stream, + 'chunk_count': len(stream_chunks), + 'chunks': stream_chunks if ns.get('with_chunks') else None, + }, + } + except Exception: + return {'status': False, 'msg': 'chat failed, %s' % traceback.format_exc()} + + +ret = await llm_chat_completions(params_kw) +return ret diff --git a/b/cntoai/llm_chat_completions_stream.dspy b/b/cntoai/llm_chat_completions_stream.dspy new file mode 100644 index 0000000..24600ff --- /dev/null +++ b/b/cntoai/llm_chat_completions_stream.dspy @@ -0,0 +1,241 @@ +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + + +def _parse_bool(value, default=True): + if value is None or value == '': + return default + if isinstance(value, bool): + return value + return str(value).lower() in ('1', 'true', 'yes', 'on') + + +def _parse_messages(ns): + raw = ns.get('messages') + if not raw: + return [] + if isinstance(raw, list): + return raw + if isinstance(raw, str): + import json + try: + return json.loads(raw) + except Exception: + return [] + return [] + + +def build_user_content(ns): + text_parts = [] + if ns.get('message'): + text_parts.append(str(ns.get('message'))) + if ns.get('text'): + text_parts.append(str(ns.get('text'))) + if ns.get('document_text'): + text_parts.append(str(ns.get('document_text'))) + + parts = [] + merged_text = '\n'.join([p for p in text_parts if p]).strip() + if merged_text: + parts.append({'type': 'text', 'text': merged_text}) + if ns.get('image_url'): + parts.append({'type': 'image_url', 'image_url': {'url': ns.get('image_url')}}) + if ns.get('image_base64'): + mime = ns.get('image_mime') or 'image/jpeg' + b64 = ns.get('image_base64') + if not str(b64).startswith('data:'): + b64 = 'data:%s;base64,%s' % (mime, b64) + parts.append({'type': 'image_url', 'image_url': {'url': b64}}) + if ns.get('document_url'): + parts.append({'type': 'file', 'file': {'file_url': ns.get('document_url')}}) + if not parts: + return '' + if len(parts) == 1 and parts[0]['type'] == 'text': + return parts[0]['text'] + return parts + + +async def _resolve_chat_config(ns, sor): + api_url = ns.get('api_url') + api_key = ns.get('api_key') + if not api_url and ns.get('model_id'): + doc_rows = await sor.sqlExe( + "SELECT api_url FROM model_api_doc WHERE model_id = '%s' LIMIT 1;" + % _escape(ns.get('model_id')), + {}, + ) + if doc_rows and doc_rows[0].get('api_url'): + api_url = doc_rows[0]['api_url'] + if not str(api_url).endswith('/chat/completions'): + api_url = str(api_url).rstrip('/') + '/chat/completions' + if not api_url: + param_rows = await sor.R('params', {'pname': 'cntoai_llm_chat_url'}) + if param_rows: + api_url = param_rows[0]['pvalue'] + else: + domain_rows = await sor.R('params', {'pname': 'cntoai_domain'}) + if domain_rows: + api_url = domain_rows[0]['pvalue'].rstrip('/') + '/llmage/v1/chat/completions' + else: + api_url = 'https://ai.atvoe.com/llmage/v1/chat/completions' + if not api_key: + userid = ns.get('userid') or await get_user() + if userid: + action = ns.get('apikey_action') or 'user_self_create' + keys = await sor.R('user_api_keys', {'userid': userid, 'action': action}) + if not keys: + keys = await sor.R('user_api_keys', {'userid': userid, 'action': 'sync'}) + if keys: + api_key = keys[0].get('opc_apikey') + if not api_key: + key_rows = await sor.R('params', {'pname': 'cntoai_llm_api_key'}) + if key_rows: + api_key = key_rows[0]['pvalue'] + return api_url, api_key + + +def _extract_stream_piece(payload): + choice = (payload.get('choices') or [{}])[0] + delta = choice.get('delta') or {} + message = choice.get('message') or {} + piece = ( + delta.get('content') + or delta.get('reasoning_content') + or message.get('content') + or choice.get('text') + or payload.get('content') + or '' + ) + if piece is None: + return '' + return str(piece) + + +def _sse_event(obj): + import json + return 'data: %s\n\n' % json.dumps(obj, ensure_ascii=False) + + +async def _iter_upstream_stream(api_url, api_key, payload): + """向上游发起流式请求,逐片 yield 文本""" + import aiohttp + import json + + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer %s' % api_key, + } + payload = dict(payload) + payload['stream'] = True + + buffer = '' + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=600)) as session: + async with session.post(api_url, headers=headers, json=payload) as response: + if response.status != 200: + err_text = await response.text() + yield {'type': 'error', 'msg': 'HTTP %s: %s' % (response.status, err_text[:500])} + return + + async for raw in response.content: + buffer += raw.decode('utf-8', errors='ignore') + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() + if not line or line.startswith(':') or not line.startswith('data:'): + continue + data = line[5:].strip() + if data == '[DONE]': + return + try: + payload_obj = json.loads(data) + piece = _extract_stream_piece(payload_obj) + if piece: + yield {'type': 'content', 'content': piece} + except Exception: + continue + + tail = buffer.strip() + if tail: + try: + body = json.loads(tail) + choice = (body.get('choices') or [{}])[0] + msg = choice.get('message') or {} + piece = msg.get('content') or choice.get('text') or '' + if piece: + yield {'type': 'content', 'content': str(piece)} + except Exception: + pass + + +async def inference_generator(request, params_kw=None, **kw): + """ + SSE 流式输出,事件格式: + {"type":"meta","model":"..."} + {"type":"content","content":"片段"} + {"type":"done","reply":"完整文本"} + {"type":"error","msg":"..."} + 结束:data: [DONE] + """ + import traceback + + ns = params_kw or {} + model = ns.get('model') + if not model: + yield _sse_event({'type': 'error', 'msg': 'model is required'}) + yield 'data: [DONE]\n\n' + return + + history = _parse_messages(ns) + user_content = build_user_content(ns) + if not user_content and not history: + yield _sse_event({'type': 'error', 'msg': 'message is required'}) + yield 'data: [DONE]\n\n' + return + + messages = list(history) + if user_content: + messages.append({'role': 'user', 'content': user_content}) + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + api_url, api_key = await _resolve_chat_config(ns, sor) + if not api_key: + yield _sse_event({'type': 'error', 'msg': '未找到 API Key'}) + yield 'data: [DONE]\n\n' + return + + yield _sse_event({'type': 'meta', 'model': model, 'stream': True}) + + parts = [] + async for evt in _iter_upstream_stream(api_url, api_key, { + 'model': model, + 'messages': messages, + }): + if evt.get('type') == 'error': + yield _sse_event(evt) + yield 'data: [DONE]\n\n' + return + if evt.get('type') == 'content': + parts.append(evt['content']) + yield _sse_event(evt) + + reply = ''.join(parts) + yield _sse_event({'type': 'done', 'reply': reply, 'model': model}) + yield 'data: [DONE]\n\n' + except Exception: + yield _sse_event({'type': 'error', 'msg': traceback.format_exc()}) + yield 'data: [DONE]\n\n' + + +async def inference(request, *args, params_kw=None, **kw): + from functools import partial + env = request._run_ns.copy() + f = partial(inference_generator, request, params_kw=params_kw, **kw) + return await env.stream_response(request, f, content_type='text/event-stream') + + +ret = await inference(request, params_kw=params_kw) +return ret diff --git a/b/cntoai/model_management_add.dspy b/b/cntoai/model_management_add.dspy new file mode 100644 index 0000000..a9c0312 --- /dev/null +++ b/b/cntoai/model_management_add.dspy @@ -0,0 +1,49 @@ +# 可写入/更新的字段(不含 id、created_at、updated_at) +_MODEL_FIELDS = ( + 'llmid', 'provider', 'model_name', 'display_name', 'model_type', + 'context_length', 'input_token_price', 'output_token_price', + 'cache_hit_input_price', 'billing_method', 'billing_unit', + 'capabilities', 'limitations', 'highlights', 'is_active', + 'description', 'listing_status', +) + + +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + + +def _build_model_dict(ns, include_listing_status=False): + data = {} + for field in _MODEL_FIELDS: + if field in ns and ns.get(field) is not None and ns.get(field) != '': + data[field] = ns.get(field) + if include_listing_status and 'listing_status' not in data: + data['listing_status'] = ns.get('listing_status', 0) + return data + + +async def model_management_add(ns={}): + """新增模型,默认待上架 listing_status=0""" + if not ns.get('provider') or not ns.get('model_name'): + return {'status': False, 'msg': 'provider and model_name are required'} + + ns_dic = _build_model_dict(ns, include_listing_status=True) + if 'listing_status' not in ns_dic: + ns_dic['listing_status'] = 0 + if 'is_active' not in ns_dic: + ns_dic['is_active'] = 1 + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + await sor.C('model_management', ns_dic) + return {'status': True, 'msg': 'create model success', 'data': ns_dic} + except Exception as e: + await sor.rollback() + return {'status': False, 'msg': 'create model failed, %s' % str(e)} + + +ret = await model_management_add(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/model_management_customer_search.dspy b/b/cntoai/model_management_customer_search.dspy new file mode 100644 index 0000000..f8e066d --- /dev/null +++ b/b/cntoai/model_management_customer_search.dspy @@ -0,0 +1,94 @@ +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + +# 客户侧可见字段(不含 listing_status、is_active 等运营字段) +_CUSTOMER_MODEL_COLUMNS = """ + id, llmid, provider, model_name, display_name, model_type, + context_length, input_token_price, output_token_price, + cache_hit_input_price, billing_method, billing_unit, + capabilities, limitations, highlights, description, sort_order +""" + + +def _customer_listed_conditions(ns): + """已上架且启用的模型;支持按厂商、模型类别筛选""" + conditions = ["listing_status = 1", "is_active = 1"] + if ns.get('provider'): + conditions.append("provider = '%s'" % _escape(ns.get('provider'))) + if ns.get('model_type'): + conditions.append("model_type = '%s'" % _escape(ns.get('model_type'))) + return ' AND '.join(conditions) + +async def model_management_customer_search(ns={}): + """ + 客户查看模型列表:仅已上架且启用的模型。 + + 可选参数: + provider (str) 厂商,精确匹配筛选 + model_type (str) 模型类别,精确匹配筛选 + current_page (int) 页码,默认 1 + page_size (int) 每页条数,默认 10 + + 返回 data: + provider_list 当前可见模型中的厂商列表(去重) + model_type_list 当前可见模型中的模型类别列表(去重) + filter_total 当前筛选条件下的模型数量 + model_list 模型列表 + page_size, current_page + + 调用示例见 model_management_customer_search.dspy + """ + page_size = int(ns.get('page_size', 1000)) + current_page = int(ns.get('current_page', 1)) + offset = (current_page - 1) * page_size + where_clause = _customer_listed_conditions(ns) + listed_base = "listing_status = 1 AND is_active = 1" + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + provider_sql = """ + SELECT DISTINCT provider FROM model_management + WHERE %s AND provider IS NOT NULL AND provider != '' + ORDER BY provider; + """ % listed_base + model_type_sql = """ + SELECT DISTINCT model_type FROM model_management + WHERE %s AND model_type IS NOT NULL AND model_type != '' + ORDER BY model_type; + """ % listed_base + + count_sql = """ + SELECT COUNT(*) AS total_count FROM model_management WHERE %s; + """ % where_clause + find_sql = """ + SELECT %s FROM model_management + WHERE %s + ORDER BY sort_order ASC + LIMIT %s OFFSET %s; + """ % (_CUSTOMER_MODEL_COLUMNS, where_clause, page_size, offset) + + provider_rows = await sor.sqlExe(provider_sql, {}) + model_type_rows = await sor.sqlExe(model_type_sql, {}) + filter_total = (await sor.sqlExe(count_sql, {}))[0]['total_count'] + model_list = await sor.sqlExe(find_sql, {}) + + return { + 'status': True, + 'msg': 'customer model search success', + 'data': { + 'provider_list': [r['provider'] for r in provider_rows], + 'model_type_list': [r['model_type'] for r in model_type_rows], + 'filter_total': filter_total, + 'page_size': page_size, + 'current_page': current_page, + 'model_list': model_list, + }, + } + except Exception as e: + return {'status': False, 'msg': 'customer model search failed, %s' % str(e)} + +ret = await model_management_customer_search(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/model_management_detail.dspy b/b/cntoai/model_management_detail.dspy new file mode 100644 index 0000000..69448ec --- /dev/null +++ b/b/cntoai/model_management_detail.dspy @@ -0,0 +1,47 @@ +# 可写入/更新的字段(不含 id、created_at、updated_at) +_MODEL_FIELDS = ( + 'llmid', 'provider', 'model_name', 'display_name', 'model_type', + 'context_length', 'input_token_price', 'output_token_price', + 'cache_hit_input_price', 'billing_method', 'billing_unit', + 'capabilities', 'limitations', 'highlights', 'is_active', + 'description', 'listing_status', +) + +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + + +def _build_model_dict(ns, include_listing_status=False): + data = {} + for field in _MODEL_FIELDS: + if field in ns and ns.get(field) is not None and ns.get(field) != '': + data[field] = ns.get(field) + if include_listing_status and 'listing_status' not in data: + data['listing_status'] = ns.get('listing_status', 0) + return data + +async def model_management_detail(ns={}): + """根据 id 获取单条模型(编辑页回显)""" + model_id = ns.get('id') + if not model_id: + return {'status': False, 'msg': 'id is required'} + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + find_sql = "SELECT * FROM model_management WHERE id = '%s' LIMIT 1;" % _escape(model_id) + result = await sor.sqlExe(find_sql, {}) + if not result: + return {'status': False, 'msg': 'model not found'} + return { + 'status': True, + 'msg': 'get model detail success', + 'data': result[0], + } + except Exception as e: + return {'status': False, 'msg': 'get model detail failed, %s' % str(e)} + +ret = await model_management_detail(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/model_management_list.dspy b/b/cntoai/model_management_list.dspy new file mode 100644 index 0000000..6789f82 --- /dev/null +++ b/b/cntoai/model_management_list.dspy @@ -0,0 +1,25 @@ +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + +async def model_management_list(ns={}): + """上架:listing_status 置为 1""" + model_id = ns.get('id') + if not model_id: + return {'status': False, 'msg': 'id is required'} + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + update_sql = """ + UPDATE model_management SET listing_status = 1 WHERE id = '%s'; + """ % _escape(model_id) + await sor.sqlExe(update_sql, {}) + return {'status': True, 'msg': 'model listed success'} + except Exception as e: + await sor.rollback() + return {'status': False, 'msg': 'model list failed, %s' % str(e)} + +ret = await model_management_list(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/model_management_move_down.dspy b/b/cntoai/model_management_move_down.dspy new file mode 100644 index 0000000..d0b9ac6 --- /dev/null +++ b/b/cntoai/model_management_move_down.dspy @@ -0,0 +1,67 @@ +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + +async def model_management_move_down(ns={}): + """ + 下移:与排序上的下一条记录交换 sort_order(已在最后则提示) + + 必填参数: + id (int|str) 模型主键 + """ + model_id = ns.get('id') + if not model_id: + return {'status': False, 'msg': 'id is required'} + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + current_sql = """ + SELECT id, sort_order FROM model_management WHERE id = '%s' LIMIT 1; + """ % _escape(model_id) + current = await sor.sqlExe(current_sql, {}) + if not current: + return {'status': False, 'msg': 'model not found'} + + cur = current[0] + cur_order = int(cur.get('sort_order') or 0) + cur_id = int(cur.get('id')) + + next_sql = """ + SELECT id, sort_order FROM model_management + WHERE (sort_order > %s) OR (sort_order = %s AND id > %s) + ORDER BY sort_order ASC, id ASC + LIMIT 1; + """ % (cur_order, cur_order, cur_id) + next_row = await sor.sqlExe(next_sql, {}) + if not next_row: + return {'status': True, 'msg': 'already at bottom', 'data': {'sort_order': cur_order}} + + nxt = next_row[0] + nxt_order = int(nxt.get('sort_order') or 0) + nxt_id = _escape(nxt.get('id')) + + swap_cur_sql = """ + UPDATE model_management SET sort_order = %s WHERE id = '%s'; + """ % (nxt_order, _escape(model_id)) + swap_nxt_sql = """ + UPDATE model_management SET sort_order = %s WHERE id = '%s'; + """ % (cur_order, nxt_id) + await sor.sqlExe(swap_cur_sql, {}) + await sor.sqlExe(swap_nxt_sql, {}) + return { + 'status': True, + 'msg': 'move down success', + 'data': { + 'id': model_id, + 'sort_order': nxt_order, + 'swapped_with_id': nxt.get('id'), + }, + } + except Exception as e: + await sor.rollback() + return {'status': False, 'msg': 'move down failed, %s' % str(e)} + +ret = await model_management_move_down(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/model_management_pin_top.dspy b/b/cntoai/model_management_pin_top.dspy new file mode 100644 index 0000000..3a47250 --- /dev/null +++ b/b/cntoai/model_management_pin_top.dspy @@ -0,0 +1,49 @@ +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + +async def model_management_pin_top(ns={}): + """ + 置顶:将模型排到全局列表最前(sort_order 设为当前最小值 - 1) + + 必填参数: + id (int|str) 模型主键 + """ + model_id = ns.get('id') + if not model_id: + return {'status': False, 'msg': 'id is required'} + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + current_sql = """ + SELECT id, sort_order FROM model_management WHERE id = '%s' LIMIT 1; + """ % _escape(model_id) + current = await sor.sqlExe(current_sql, {}) + if not current: + return {'status': False, 'msg': 'model not found'} + + min_sql = "SELECT MIN(sort_order) AS min_order FROM model_management;" + min_order = int((await sor.sqlExe(min_sql, {}))[0].get('min_order') or 0) + current_order = int(current[0].get('sort_order') or 0) + + if current_order <= min_order: + return {'status': True, 'msg': 'already at top', 'data': {'sort_order': current_order}} + + new_order = min_order - 1 + update_sql = """ + UPDATE model_management SET sort_order = %s WHERE id = '%s'; + """ % (new_order, _escape(model_id)) + await sor.sqlExe(update_sql, {}) + return { + 'status': True, + 'msg': 'pin to top success', + 'data': {'id': model_id, 'sort_order': new_order}, + } + except Exception as e: + await sor.rollback() + return {'status': False, 'msg': 'pin to top failed, %s' % str(e)} + +ret = await model_management_pin_top(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/model_management_search.dspy b/b/cntoai/model_management_search.dspy new file mode 100644 index 0000000..2547234 --- /dev/null +++ b/b/cntoai/model_management_search.dspy @@ -0,0 +1,96 @@ +# 可写入/更新的字段(不含 id、created_at、updated_at) +_MODEL_FIELDS = ( + 'llmid', 'provider', 'model_name', 'display_name', 'model_type', + 'context_length', 'input_token_price', 'output_token_price', + 'cache_hit_input_price', 'billing_method', 'billing_unit', + 'capabilities', 'limitations', 'highlights', 'is_active', + 'description', 'listing_status', 'sort_order', +) + + +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + + +def _build_model_dict(ns, include_listing_status=False): + data = {} + for field in _MODEL_FIELDS: + if field in ns and ns.get(field) is not None and ns.get(field) != '': + data[field] = ns.get(field) + if include_listing_status and 'listing_status' not in data: + data['listing_status'] = ns.get('listing_status', 0) + return data + +async def model_management_search(ns={}): + """ + 分页查询模型列表,支持按 model_name / model_type / provider 筛选。 + 返回模型总数、待上架总数、已上架总数,以及厂商列表、模型类型列表。 + """ + import traceback + + page_size = int(ns.get('page_size', 1000)) + current_page = int(ns.get('current_page', 1)) + offset = (current_page - 1) * page_size + + conditions = ['1=1'] + if ns.get('display_name'): + display_name = ns.get('display_name') + # 模糊查询 + conditions.append(f"display_name LIKE '%%%%{display_name}%%%%'") + if ns.get('model_type'): + conditions.append("model_type = '%s'" % _escape(ns.get('model_type'))) + if ns.get('provider'): + conditions.append("provider = '%s'" % _escape(ns.get('provider'))) + if ns.get('listing_status') is not None and ns.get('listing_status') != '': + conditions.append("listing_status = '%s'" % _escape(ns.get('listing_status'))) + + where_clause = ' AND '.join(conditions) + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + stats_sql = """SELECT COUNT(*) AS total_count, SUM(CASE WHEN listing_status = 0 THEN 1 ELSE 0 END) AS pending_count, SUM(CASE WHEN listing_status = 1 THEN 1 ELSE 0 END) AS listed_count FROM model_management;""" + stats_li = await sor.sqlExe(stats_sql, {}) + stats = stats_li[0] if stats_li else {} + + provider_sql = """ + SELECT DISTINCT provider FROM model_management + WHERE provider IS NOT NULL AND provider != '' + ORDER BY provider; + """ + model_type_sql = """ + SELECT DISTINCT model_type FROM model_management + WHERE model_type IS NOT NULL AND model_type != '' + ORDER BY model_type; + """ + + count_sql = """SELECT COUNT(*) AS total_count FROM model_management WHERE %s;""" % where_clause + filter_total = (await sor.sqlExe(count_sql, {}))[0]['total_count'] + + find_sql = """SELECT * FROM model_management WHERE %s ORDER BY sort_order ASC LIMIT %s OFFSET %s;""" % (where_clause, page_size, offset) + provider_rows = await sor.sqlExe(provider_sql, {}) + model_type_rows = await sor.sqlExe(model_type_sql, {}) + model_list = await sor.sqlExe(find_sql, {}) + + return { + 'status': True, + 'msg': 'search model success', + 'data': { + 'total_count': stats.get('total_count', 0), + 'pending_count': int(stats.get('pending_count') or 0), + 'listed_count': int(stats.get('listed_count') or 0), + 'provider_list': [r['provider'] for r in provider_rows], + 'model_type_list': [r['model_type'] for r in model_type_rows], + 'filter_total': filter_total, + 'page_size': page_size, + 'current_page': current_page, + 'model_list': model_list, + }, + } + except Exception as e: + return {'status': False, 'msg': 'search model failed, %s' % traceback.format_exc()} + +ret = await model_management_search(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/model_management_unlist.dspy b/b/cntoai/model_management_unlist.dspy new file mode 100644 index 0000000..8900dda --- /dev/null +++ b/b/cntoai/model_management_unlist.dspy @@ -0,0 +1,25 @@ +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + +async def model_management_unlist(ns={}): + """下架:listing_status 置为 0(统计归入待上架)""" + model_id = ns.get('id') + if not model_id: + return {'status': False, 'msg': 'id is required'} + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + update_sql = """ + UPDATE model_management SET listing_status = 0 WHERE id = '%s'; + """ % _escape(model_id) + await sor.sqlExe(update_sql, {}) + return {'status': True, 'msg': 'model unlisted success'} + except Exception as e: + await sor.rollback() + return {'status': False, 'msg': 'model unlist failed, %s' % str(e)} + +ret = await model_management_unlist(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/model_management_update.dspy b/b/cntoai/model_management_update.dspy new file mode 100644 index 0000000..854de52 --- /dev/null +++ b/b/cntoai/model_management_update.dspy @@ -0,0 +1,45 @@ +# 可写入/更新的字段(不含 id、created_at、updated_at) +_MODEL_FIELDS = ( + 'llmid', 'provider', 'model_name', 'display_name', 'model_type', + 'context_length', 'input_token_price', 'output_token_price', + 'cache_hit_input_price', 'billing_method', 'billing_unit', + 'capabilities', 'limitations', 'highlights', 'is_active', + 'description', 'listing_status', +) + + +def _escape(value): + if value is None: + return None + return str(value).replace("'", "''") + + +def _build_model_dict(ns, include_listing_status=False): + data = {} + for field in _MODEL_FIELDS: + if field in ns and ns.get(field) is not None and ns.get(field) != '': + data[field] = ns.get(field) + if include_listing_status and 'listing_status' not in data: + data['listing_status'] = ns.get('listing_status', 0) + return data + +async def model_management_update(ns={}): + """编辑模型,id 必传""" + model_id = ns.get('id') + if not model_id: + return {'status': False, 'msg': 'id is required'} + + ns_dic = _build_model_dict(ns) + ns_dic['id'] = model_id + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + await sor.U('model_management', ns_dic) + return {'status': True, 'msg': 'update model success'} + except Exception as e: + await sor.rollback() + return {'status': False, 'msg': 'update model failed, %s' % str(e)} + +ret = await model_management_update(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/process_user_billing.dspy b/b/cntoai/process_user_billing.dspy new file mode 100644 index 0000000..35e9ed5 --- /dev/null +++ b/b/cntoai/process_user_billing.dspy @@ -0,0 +1,415 @@ +async def _lookup_product(sor, providername, productname): + """ + 按厂商名 + 产品名解析 product 记录。 + 依次尝试:provider.name + product.name → providerpid → 仅 product.name + """ + provider_list = await sor.R('provider', {'name': providername, 'del_flg': '0'}) + if provider_list: + product_list = await sor.R( + 'product', + {'name': productname, 'providerid': provider_list[0]['orgid'], 'del_flg': '0'}, + ) + if product_list: + return product_list[0] + + product_list = await sor.R('product', {'name': productname, 'providerid': provider_list[0]['orgid'], 'del_flg': '0'}) + if product_list: + return product_list[0] + + return None + + +async def _charge_order(sor, orderid, order_type='NEW'): + """ + 确认支付:校验余额 → order2bill → BillAccounting → 更新订单/账单 → customer_goods。 + 逻辑来自 get_baidu_orderlist.dspy 的 affirmbz_order。 + """ + order_rows = await sor.R('bz_order', {'id': orderid}) + if not order_rows: + debug(f"订单不存在") + return {'status': 'error', 'msg': '订单不存在'} + + order_row = order_rows[0] + product_url = None + + await get_business_date(sor=None) + + count = await getCustomerBalance(sor, order_row['customerid']) + if count is None: + count = 0 + if count - float(order_row['amount']) < 0: + pricedifference = count - round(order_row['amount'], 2) + debug(f"账户余额不足,订单金额: {order_row['amount']}, 账户余额: {count}, 差额: {pricedifference}") + return { + 'status': 'error', + 'msg': '账户余额不足', + 'pricedifference': round(pricedifference, 10), + } + + await order2bill(orderid, sor) + bills = await sor.R('bill', {'orderid': orderid, 'del_flg': '0'}) + try: + for bill in bills: + ba = BillAccounting(bill) + await ba.accounting(sor) + dates = datetime.datetime.now() + await sor.U('bz_order', {'id': orderid, 'order_status': '1', 'create_at': dates}) + await sor.U('bill', {'id': orderid, 'bill_state': '1'}) + + # 暂时不处理customer_goods + # order_goods = await sor.R('order_goods', {'orderid': orderid}) + # for item in order_goods: + # if order_type == 'REFUND': + # resource_find_sql = ( + # "select id from customer_goods where resourceid = '%s';" + # % item['resourceids'] + # ) + # resource_find_li = await sor.sqlExe(resource_find_sql, {}) + # resource_find_id = resource_find_li[0]['id'] + # await sor.U('customer_goods', {'id': resource_find_id, 'del_flg': '1'}) + # elif order_type == 'RENEW': + # resource_find_sql = ( + # "select id from customer_goods where FIND_IN_SET('%s', resourceid) and del_flg = '0';" + # % item['resourceids'] + # ) + # resource_find_li = await sor.sqlExe(resource_find_sql, {}) + # resource_find_id = resource_find_li[0]['id'] + # await sor.U( + # 'customer_goods', + # { + # 'id': resource_find_id, + # 'start_date': item['resourcestarttime'], + # 'expire_date': item['resourceendtime'], + # }, + # ) + # else: + # if item.get('chargemode') == 'postpay' and item.get('orderkey') == 'snapshot': + # continue + + # product = await sor.R('product', {'id': item['productid']}) + # now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + # nss = { + # 'id': uuid(), + # 'providerrid': product[0]['providerid'], + # 'productname': product[0]['name'], + # 'productdesc': product[0]['description'], + # 'customerid': order_row['customerid'], + # 'productid': product[0]['id'], + # 'specdataid': item.get('spec_id'), + # 'orderid': order_row['id'], + # 'start_date': item.get('resourcestarttime') or now_str, + # 'expire_date': item.get('resourceendtime'), + # 'resourceid': item.get('resourceids') or '', + # 'orderkey': item.get('orderkey'), + # } + + # if product_url: + # nss['product_url'] = product_url + # else: + # spec = ( + # json.loads(product[0]['spec_note']) + # if isinstance(product[0]['spec_note'], str) + # else product[0]['spec_note'] + # ) + # spec_list_url = [ + # spec_item['value'] + # for spec_item in (spec or []) + # if spec_item.get('configName') == 'listUrl' + # ] + # nss['product_url'] = ( + # spec_list_url[0] + # if spec_list_url + # else 'https://console.vcp.baidu.com/bcc/#/bcc/instance/list' + # ) + + # await sor.C('customer_goods', nss) + debug(f"支付成功") + return {'status': True, 'msg': '支付成功'} + except Exception as error: + debug(f"支付失败: {error}") + return {'status': 'error', 'msg': str(error)} + +async def calc_price_by_saleprotocol(sor, org, product_id, supply_price, quantity=1): + """ + 查 saleprotocol、product_salemode,按折扣计算应付金额。 + + :param sor: sqlor 上下文(kboss) + :param org: organization 记录,须含 id、parentid + :param product_id: product 表主键 + :param supply_price: 供应价/目录价(折扣前单价,与百度脚本 catalogPrice / itemFee.price 同义) + :param quantity: 数量,默认 1 + :return: dict + 成功: status=True, amount(行总金额), price(折后单价), list_price, discount + 失败: status='error', msg + """ + try: + supply_price = abs(float(supply_price)) + quantity = int(quantity) + except (TypeError, ValueError): + debug(f"calc_price_by_saleprotocol supply_price / quantity 必须为有效数字") + return {'status': 'error', 'msg': 'supply_price / quantity 必须为有效数字'} + + if supply_price <= 0: + debug(f"calc_price_by_saleprotocol supply_price 必须大于 0") + return {'status': 'error', 'msg': 'supply_price 必须大于 0'} + if quantity <= 0: + debug(f"calc_price_by_saleprotocol quantity 必须大于 0") + return {'status': 'error', 'msg': 'quantity 必须大于 0'} + + saleprotocol_to_person = await sor.R( + 'saleprotocol', + {'bid_orgid': org['id'], 'offer_orgid': org['parentid'], 'del_flg': '0'}, + ) + saleprotocol_to_all = await sor.R( + 'saleprotocol', + { + 'bid_orgid': '*', + 'offer_orgid': org['parentid'], + 'del_flg': '0', + 'salemode': '0', + }, + ) + + product_salemode = None + if saleprotocol_to_person: + product_salemode = await sor.R( + 'product_salemode', + { + 'protocolid': saleprotocol_to_person[0]['id'], + 'productid': product_id, + 'del_flg': '0', + }, + ) + if not product_salemode and saleprotocol_to_all: + product_salemode = await sor.R( + 'product_salemode', + { + 'protocolid': saleprotocol_to_all[0]['id'], + 'productid': product_id, + 'del_flg': '0', + }, + ) + elif saleprotocol_to_all: + product_salemode = await sor.R( + 'product_salemode', + { + 'protocolid': saleprotocol_to_all[0]['id'], + 'productid': product_id, + 'del_flg': '0', + }, + ) + + if not product_salemode: + debug(f"calc_price_by_saleprotocol 还未上线这个产品的协议配置") + return {'status': 'error', 'msg': '还未上线这个产品的协议配置'} + + discount = product_salemode[0]['discount'] + list_price = supply_price + price = abs(round(list_price * discount, 12)) + amount = abs(round(price * quantity, 12)) + + return { + 'status': True, + 'amount': amount, + 'price': price, + 'list_price': list_price, + 'discount': discount, + 'protocolid': product_salemode[0]['protocolid'], + 'product_salemode_id': product_salemode[0].get('id'), + } + +async def process_user_billing(ns={}): + """ + 通用记账扣费:创建本地订单 → 校验余额 → 出账记账。 + + :param userid: 用户 ID + :param providername: 厂商名称(写入 bz_order.source,并用于查 product) + :param productname: 产品名称(写入 servicename,并用于查 product) + :param amount: 扣费金额;use_saleprotocol='error' 时为最终扣费额; + use_saleprotocol=True 时为供应价/目录价(折扣前单价),走协议算价 + :param use_saleprotocol: 是否启用 saleprotocol_pricing 协议折扣算价,默认 'error' 直接按 amount 扣费 + :param quantity: 仅 use_saleprotocol=True 时生效,数量默认 1 + :return: dict,含 status、msg;成功时含 orderid、amount + """ + # 存储输入值到usage表 + db = DBPools() + async with db.sqlorContext('kboss') as sor: + usage_ns = { + 'id': uuid(), + 'userid': ns.get('userid'), + 'apikey': ns.get('apikey'), + 'llmid': ns.get('llmid'), + 'original_price': ns.get('amount'), + 'usage_content': json.dumps(ns.get('usage')) if isinstance(ns.get('usage'), dict) else ns.get('usage') + } + await sor.C('model_usage', usage_ns) + + apikey = ns.get('apikey') + userid = ns.get('userid') + providername = ns.get('providername') + productname = ns.get('productname') + amount = ns.get('amount') + use_saleprotocol = ns.get('use_saleprotocol', True) + quantity = int(ns.get('quantity', 1)) + + llmid = ns.get('llmid') + if not llmid: + debug(f"{userid} process_user_billing llmid必传") + return { + 'status': 'error', + 'msg': 'llmid必传' + } + + try: + amount = round(float(amount), 12) + except (TypeError, ValueError): + debug(f"{userid} process_user_billing amount 必须为有效数字") + return {'status': 'error', 'msg': 'amount 必须为有效数字'} + + if amount <= 0: + debug(f"{userid} process_user_billing amount 必须大于 0") + return {'status': 'error', 'msg': 'amount 必须大于 0'} + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + product_li = await sor.R('product', {'providerpid': llmid, 'del_flg': '0'}) + if not product_li: + debug(f"{userid} process_user_billing 未找到对应产品,请确认") + return { + 'status': 'error', + 'msg': '未找到对应产品,请确认' + } + product = product_li[0] + productname = product['name'] + providerid = product['providerid'] + providername_list = await sor.R('organization', {'id': providerid}) + if not providername_list: + debug(f"{userid} process_user_billing 厂商不存在 %s" % providername) + return { + 'status': 'error', + 'msg': '厂商不存在 %s' % providername + } + providername = providername_list[0]['orgname'] + + # userid_li = await sor.R('user_api_keys', {'opc_apikey': apikey}) + # if not userid_li: + # debug(f"{userid} process_user_billing apikey无效,请联系管理员") + # return { + # 'status': 'error', + # 'msg': 'apikey无效,请联系管理员' + # } + # userid = userid_li[0]['userid'] + + user_list = await sor.R('users', {'id': userid}) + if not user_list: + debug(f"{userid} process_user_billing 用户不存在 %s" % userid) + return {'status': 'error', 'msg': '用户不存在 %s' % userid} + + org_list = await sor.R('organization', {'id': user_list[0]['orgid']}) + if not org_list: + debug(f"{userid} process_user_billing 用户所属机构不存在") + return {'status': 'error', 'msg': '用户所属机构不存在'} + + customerid = org_list[0]['id'] + # product = await _lookup_product(sor, providername, productname) + # if not product: + # return { + # 'status': 'error', + # 'msg': '未找到对应产品,请确认 providername/productname 与库中 provider、product 配置一致', + # } + + list_price = amount + unit_price = amount + discount = 1 + originalprice = amount + + if use_saleprotocol: + price_res = await calc_price_by_saleprotocol( + sor, org_list[0], product['id'], amount, quantity=quantity, + ) + if not price_res['status']: + return price_res + debug(price_res) + debug('list_price %s' % list_price) + amount = price_res['amount'] + list_price = price_res['list_price'] + unit_price = price_res['price'] + discount = price_res['discount'] + originalprice = list_price * quantity + + balance = await getCustomerBalance(sor, customerid) + if balance is None: + balance = 0 + if amount > balance: + return { + 'status': 'error', + 'msg': '账户余额不足', + 'pricedifference': round(balance - amount, 12), + } + + order_id = uuid() + now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + bz_ns = { + 'id': order_id, + 'order_status': '0', + 'business_op': 'BUY', + 'userid': userid, + 'customerid': customerid, + 'order_date': now_str, + 'source': providername, + 'amount': amount, + 'originalprice': round(originalprice, 12), + 'ordertype': 'prepay', + 'servicename': productname, + 'is_big_model': 1 + } + await sor.C('bz_order', bz_ns) + + goods_ns = { + 'id': uuid(), + 'orderid': order_id, + 'productid': product['id'], + 'providerid': product['providerid'], + 'list_price': list_price, + 'discount': discount, + 'quantity': quantity if use_saleprotocol else 1, + 'price': unit_price, + 'amount': amount, + 'chargemode': 'prepay', + 'servicename': productname, + 'resourceids': '', + 'resourcestarttime': now_str, + 'resourceendtime': None, + 'is_big_model': 1 + } + await sor.C('order_goods', goods_ns) + + charge_res = await _charge_order(sor, order_id, order_type='NEW') + if not charge_res['status']: + await sor.rollback() + return charge_res + + await sor.U('model_usage', {'id': usage_ns['id'], 'orderid': order_id, 'bill_status': 1}) + + result = { + 'status': 'ok', + 'msg': '扣费成功', + 'orderid': order_id, + 'amount': amount, + 'productid': product['id'], + } + if use_saleprotocol: + result['discount'] = discount + result['list_price'] = list_price + result['price'] = unit_price + return result + except Exception as e: + sor.rollback() + return { + 'status': 'error', + 'msg': str(e) + } + +ret = await process_user_billing(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/sync_cn_ai_user.dspy b/b/cntoai/sync_cn_ai_user.dspy new file mode 100644 index 0000000..f5ab279 --- /dev/null +++ b/b/cntoai/sync_cn_ai_user.dspy @@ -0,0 +1,131 @@ +async def sync_cn_ai_user(ns={}): + import aiohttp + + user_info = None + if ns.get('userid'): + userid = ns.get('userid') + db = DBPools() + async with db.sqlorContext('kboss') as sor: + user_info = await sor.R('users', {'id': userid}) + if not user_info: + return { + 'status': False, + 'msg': '未找到匹配的用户' + } + userid = user_info[0]['id'] + orgid = user_info[0]['orgid'] + username = user_info[0]['username'] + name = user_info[0]['name'] + email = user_info[0]['email'] + + debug(f"sync_cn_ai_user同步用户: {userid}, {orgid}, {username}, {name}, {email}") + # 目标URL + # domain 从数据库params表中获取到pname=cntoai_domain的pvalue值 + domain = None + db = DBPools() + async with db.sqlorContext('kboss') as sor: + domain = await sor.R('params', {'pname': 'cntoai_domain'}) + if domain: + domain = domain[0]['pvalue'] + else: + debug(f"sync_cn_ai_user未找到域名") + return { + 'status': False, + 'msg': '未找到域名' + } + already_sync_user_key = await sor.R('params', {'pname': 'cntoai_already_sync_user_key'}) + if already_sync_user_key: + already_sync_user_key = already_sync_user_key[0]['pvalue'] + else: + debug(f"sync_cn_ai_user未找到已同步用户key") + return { + 'status': False, + 'msg': '未找到已同步用户key' + } + already_sync_user_dappid = await sor.R('params', {'pname': 'cntoai_already_sync_user_dappid'}) + if already_sync_user_dappid: + already_sync_user_dappid = already_sync_user_dappid[0]['pvalue'] + else: + debug(f"sync_cn_ai_user未找到已同步用户dappid") + return { + 'status': False, + 'msg': '未找到已同步用户dappid' + } + + url = f"{domain}/rbac/usersync" + + # 请求头 + headers = { + "Content-Type": "application/json", + "Authorization": "Bearer %s" % already_sync_user_key + } + + # 请求体数据 + payload = { + "action": "single", + "dappid": already_sync_user_dappid, + "user": { + "id": userid, + "orgid": orgid, + "username": username, + "name": name, + "email": email + } + } + + try: + # 创建一个异步会话 + result_sysnc = None + async with aiohttp.ClientSession() as session: + # 发送POST请求 + async with session.post(url, headers=headers, data=json.dumps(payload)) as response: + # 打印响应状态码 + debug(f"sync_cn_ai_user状态码: {response.status}") + result_sysnc = await response.json() + + if not result_sysnc.get('status') == 'success': + debug(f"sync_cn_ai_user同步用户失败: {result_sysnc}") + return { + 'status': False + } + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + # user_api_keys表格 userid/opc_apikey + # 首先判断apikey是否存在 + apikey = result_sysnc['data'][0].get('apikey') + appid = result_sysnc['data'][0].get('appid') + secretkey = result_sysnc['data'][0].get('secretkey') + + records = await sor.R('user_api_keys', {'opc_apikey': apikey}) + if records: + debug(f"sync_cn_ai_user用户{payload['user']['id']}已存在") + return { + 'status': False, + 'msg': '用户opc_apikey已存在' + } + await sor.C('user_api_keys', { + 'userid': userid, + 'opc_apikey': apikey, + 'appid': appid, + 'secretkey': secretkey, + 'action': 'sync', + 'expire_time': None, + }) + + debug(f"sync_cn_ai_user用户{payload['user']['id']}同步成功") + return { + 'status': True, + 'msg': '用户同步成功' + } + + except Exception as e: + debug(f"sync_cn_ai_user{userid}同步用户失败: {e}") + return { + 'status': False, + 'msg': f"sync_cn_ai_user{userid}同步用户失败: {e}" + } + + +ret = await sync_cn_ai_user(params_kw) +return ret \ No newline at end of file diff --git a/b/cntoai/test_chat.py b/b/cntoai/test_chat.py new file mode 100644 index 0000000..3432eac --- /dev/null +++ b/b/cntoai/test_chat.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +cntoai 对话相关接口联调测试(dev.opencomputing.cn) + +用法: + pip install requests + set CNTOAI_USERID=你的用户id + set CNTOAI_API_KEY=你的api_key + python test_chat.py + +可选环境变量: + CNTOAI_BASE_URL 默认 https://dev.opencomputing.cn + CNTOAI_MODEL 默认 qwen3.6-plus + CNTOAI_LLM_API_URL 默认 https://ai.atvoe.com/llmage/v1/chat/completions + CNTOAI_COOKIE 浏览器 Cookie(未传 userid 时用于鉴权) + +单测: + python test_chat.py --only models + python test_chat.py --only completions + python test_chat.py --only send + python test_chat.py --only session +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from typing import Any, Dict, Optional, Tuple + +try: + import requests +except ImportError: + print("请先安装依赖: pip install requests") + sys.exit(1) + + +BASE_URL = "https://dev.opencomputing.cn" +USERID = "hSqZuekZ1yKmhKmCN9UAK" +API_KEY = "sk-c22d6573e85a4d3fa8ab932386cf2909" +# API_URL = "https://ai.atvoe.com/llmage/v1/chat/completions" +API_URL = "https://api.deepseek.com/chat/completions" +# MODEL = "qwen3.6-plus" +MODEL = "deepseek-v4-pro" +COOKIE = "".strip() +TIMEOUT = int(120) + + +class ChatApiClient: + def __init__(self, base_url: str = BASE_URL): + self.base_url = base_url.rstrip("/") + self.session = requests.Session() + self.session.headers.update({"Accept": "application/json"}) + if COOKIE: + self.session.headers["Cookie"] = COOKIE + + def _url(self, path: str) -> str: + path = path if path.startswith("/") else f"/{path}" + return f"{self.base_url}{path}" + + def _auth(self, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + params: Dict[str, Any] = {} + if USERID: + params["userid"] = USERID + if API_KEY: + params["api_key"] = API_KEY + if API_URL: + params["api_url"] = API_URL + if extra: + params.update(extra) + return params + + def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + resp = self.session.get(self._url(path), params=self._auth(params), timeout=TIMEOUT) + return self._parse(resp) + + def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + resp = self.session.post( + self._url(path), + json=self._auth(data), + headers={"Content-Type": "application/json"}, + timeout=TIMEOUT, + ) + return self._parse(resp) + + @staticmethod + def _parse(resp: requests.Response) -> Dict[str, Any]: + print(f" HTTP {resp.status_code} {resp.url[:120]}...") + try: + return resp.json() + except Exception: + return {"status": False, "msg": f"非 JSON 响应: {resp.text[:300]}"} + + +def ok(name: str, data: Dict[str, Any]) -> bool: + passed = data.get("status") is True + tag = "PASS" if passed else "FAIL" + print(f"\n[{tag}] {name}") + print(json.dumps(data, ensure_ascii=False, indent=2)[:2000]) + if not passed: + print(f" -> {data.get('msg', 'unknown error')}") + return passed + + +def test_model_list(client: ChatApiClient) -> bool: + print("\n=== GET /cntoai/model_management_customer_search.dspy ===") + data = client.get("/cntoai/model_management_customer_search.dspy", { + "page_size": 20, + "current_page": 1, + }) + if ok("模型列表", data) and data.get("data"): + models = data["data"].get("model_list") or [] + print(f" 共 {len(models)} 个模型") + if models: + m0 = models[0] + print(f" 首个: {m0.get('model_name')} / {m0.get('display_name')}") + return data.get("status") is True + + +def test_llm_chat_completions(client: ChatApiClient) -> bool: + print("\n=== POST /cntoai/llm_chat_completions.dspy ===") + data = client.post("/cntoai/llm_chat_completions.dspy", { + "model": MODEL, + "message": "用一句话介绍你自己", + "stream": True, + }) + if ok("直连模型", data) and data.get("data"): + print(f" 回复摘要: {(data['data'].get('reply') or '')[:200]}") + return data.get("status") is True + + +def test_chat_send( + client: ChatApiClient, + session_id: Optional[str] = None, +) -> Tuple[bool, Optional[str]]: + print("\n=== POST /cntoai/chat_send.dspy ===") + payload: Dict[str, Any] = { + "model": MODEL, + "message": ( + "你好,这是 test_chat.py 自动化测试" + if not session_id + else "继续,用一句话回复我" + ), + "stream": True, + } + if session_id: + payload["session_id"] = session_id + data = client.post("/cntoai/chat_send.dspy", payload) + if ok("发送消息", data) and data.get("data"): + sid = data["data"].get("session_id") + print(f" session_id: {sid}") + print(f" 回复摘要: {(data['data'].get('reply') or '')[:200]}") + return True, sid + return False, session_id + + +def test_chat_session_list(client: ChatApiClient) -> bool: + print("\n=== GET /cntoai/chat_session_list.dspy ===") + data = client.get("/cntoai/chat_session_list.dspy", {"page_size": 10}) + if ok("会话列表", data) and data.get("data"): + sessions = data["data"].get("sessions") or [] + print(f" 共 {data['data'].get('total_count', len(sessions))} 条会话") + for s in sessions[:3]: + print(f" - {s.get('id')} | {s.get('title')}") + return data.get("status") is True + + +def test_chat_session_messages(client: ChatApiClient, session_id: str) -> bool: + print("\n=== GET /cntoai/chat_session_messages.dspy ===") + data = client.get("/cntoai/chat_session_messages.dspy", {"session_id": session_id}) + if ok("会话消息", data) and data.get("data"): + msgs = data["data"].get("messages") or [] + print(f" 消息数: {len(msgs)}") + for m in msgs: + print(f" [{m.get('role')}] {str(m.get('content') or '')[:80]}") + return data.get("status") is True + + +def test_chat_session_delete(client: ChatApiClient, session_id: str) -> bool: + print("\n=== GET /cntoai/chat_session_delete.dspy ===") + data = client.get("/cntoai/chat_session_delete.dspy", {"session_id": session_id}) + return ok("删除会话", data) + + +def check_config(require_userid: bool = True) -> bool: + print("配置:") + print(f" BASE_URL = {BASE_URL}") + print(f" MODEL = {MODEL}") + print(f" API_URL = {API_URL or '(走服务端配置)'}") + print(f" USERID = {USERID or '(未设置)'}") + print(f" API_KEY = {'已设置' if API_KEY else '(未设置)'}") + print(f" COOKIE = {'已设置' if COOKIE else '(未设置)'}") + + if require_userid and not USERID and not COOKIE: + print("\n错误: 持久化接口需要 CNTOAI_USERID 或 CNTOAI_COOKIE") + return False + if not API_KEY: + print("\n警告: 未设置 CNTOAI_API_KEY,将依赖服务端 Key") + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description="cntoai chat API 联调测试") + parser.add_argument( + "--only", + choices=["models", "completions", "send", "session", "delete", "all"], + default="all", + ) + parser.add_argument("--keep-session", action="store_true", help="不删除测试会话") + parser.add_argument("--base-url", default=BASE_URL) + args = parser.parse_args() + + client = ChatApiClient(base_url=args.base_url) + results = [] + session_id: Optional[str] = None + + if args.only in ("all", "models"): + results.append(("models", test_model_list(client))) + + if args.only in ("all", "completions"): + if check_config(require_userid=False): + results.append(("completions", test_llm_chat_completions(client))) + else: + results.append(("completions", False)) + + if args.only in ("all", "send", "session", "delete"): + if not check_config(require_userid=True): + return 1 + + if args.only in ("all", "send"): + passed, session_id = test_chat_send(client) + results.append(("send_1", passed)) + if passed and session_id: + time.sleep(1) + passed2, session_id = test_chat_send(client, session_id=session_id) + results.append(("send_2_multiturn", passed2)) + + if args.only in ("all", "session") and session_id: + results.append(("session_list", test_chat_session_list(client))) + results.append(("session_messages", test_chat_session_messages(client, session_id))) + elif args.only == "session": + results.append(("session_list", test_chat_session_list(client))) + + if args.only in ("all", "delete") and session_id and not args.keep_session: + results.append(("delete", test_chat_session_delete(client, session_id))) + elif session_id and args.keep_session: + print(f"\n保留测试会话: {session_id}") + + print("\n" + "=" * 50) + print("汇总:") + failed = sum(1 for _, p in results if not p) + for name, passed in results: + print(f" {'OK' if passed else 'FAIL'} {name}") + print("=" * 50) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/b/cntoai/test_demo.py b/b/cntoai/test_demo.py new file mode 100644 index 0000000..b04e887 --- /dev/null +++ b/b/cntoai/test_demo.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +chat_send_stream.dspy SSE 流式接口测试 + +用法: + pip install requests + python test_demo.py + +环境变量(可选,覆盖下方默认值): + CNTOAI_BASE_URL / CNTOAI_USERID / CNTOAI_API_KEY + CNTOAI_MODEL / CNTOAI_LLM_API_URL / CNTOAI_MESSAGE +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any, Dict, Generator, List, Optional + +try: + import requests +except ImportError: + print("请先安装: pip install requests") + sys.exit(1) + + +BASE_URL = os.environ.get("CNTOAI_BASE_URL", "https://dev.opencomputing.cn").rstrip("/") +USERID = os.environ.get("CNTOAI_USERID", "hSqZuekZ1yKmhKmCN9UAK").strip() +API_KEY = os.environ.get("CNTOAI_API_KEY", "sk-c22d6573e85a4d3fa8ab932386cf2909").strip() +API_URL = os.environ.get("CNTOAI_LLM_API_URL", "https://api.deepseek.com/v1/chat/completions").strip() +MODEL = os.environ.get("CNTOAI_MODEL", "deepseek-chat").strip() +MESSAGE = os.environ.get("CNTOAI_MESSAGE", "你好,请用三句话介绍你自己").strip() +TIMEOUT = int(os.environ.get("CNTOAI_TIMEOUT", "300")) + +STREAM_PATH = "/cntoai/chat_send_stream.dspy" + + +def build_payload(session_id: Optional[str] = None, message: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "model": MODEL, + "message": message or MESSAGE, + "userid": USERID, + "api_key": API_KEY, + "api_url": API_URL, + } + if session_id: + payload["session_id"] = session_id + return payload + + +def parse_sse_text(text: str) -> List[Dict[str, Any]]: + events: List[Dict[str, Any]] = [] + for line in text.splitlines(): + line = line.strip() + if not line.startswith("data:"): + continue + data = line[5:].strip() + if data == "[DONE]": + break + try: + events.append(json.loads(data)) + except json.JSONDecodeError: + print(f"[warn] 无法解析: {line[:200]}") + return events + + +def parse_sse_stream(response: requests.Response) -> Generator[Dict[str, Any], None, None]: + """ + 按字节缓冲解析 SSE。 + 勿用 iter_lines(decode_unicode=True):TCP 分块可能截断 UTF-8 多字节字符,导致乱码和 JSON 解析失败。 + """ + buffer = b"" + for chunk in response.iter_content(chunk_size=4096): + if not chunk: + continue + buffer += chunk + while b"\n" in buffer: + line_bytes, buffer = buffer.split(b"\n", 1) + if not line_bytes.strip(): + continue + line = line_bytes.decode("utf-8").strip() + if not line.startswith("data:"): + continue + data = line[5:].strip() + if data == "[DONE]": + return + try: + yield json.loads(data) + except json.JSONDecodeError: + print(f"\n[warn] JSON 解析失败: {line[:120]}...") + + tail = buffer.strip() + if tail: + line = tail.decode("utf-8", errors="replace").strip() + if line.startswith("data:"): + data = line[5:].strip() + if data and data != "[DONE]": + try: + yield json.loads(data) + except json.JSONDecodeError: + pass + + +def diagnose_empty_response(resp: requests.Response) -> None: + ctype = resp.headers.get("Content-Type", "") + body = resp.content or b"" + print("\n[诊断] 响应体为空或无可解析 SSE") + print(f" Content-Type : {ctype}") + print(f" body 长度 : {len(body)}") + if body: + print(f" body 前 500B : {body[:500]!r}") + if "text/html" in ctype and len(body) == 0: + print("\n 可能原因: chat_send_stream.dspy 未执行 inference 入口。") + print(" 请确认文件末尾包含:") + print(" ret = await inference(request, params_kw=params_kw)") + print(" return ret") + print(" 并重新部署到 dev 后再测。") + + +def test_chat_send_stream(session_id: Optional[str] = None, message: Optional[str] = None) -> Optional[str]: + url = BASE_URL + STREAM_PATH + payload = build_payload(session_id=session_id, message=message) + + print("=" * 60) + print("chat_send_stream.dspy 流式测试") + print(f" URL : {url}") + print(f" MODEL : {MODEL}") + print(f" USERID : {USERID}") + print(f" API_URL : {API_URL}") + print(f" message : {payload.get('message')}") + if session_id: + print(f" session : {session_id}") + print("=" * 60) + + if not USERID: + print("错误: 请设置 CNTOAI_USERID") + return None + + resp = requests.post( + url, + json=payload, + headers={ + "Accept": "text/event-stream", + "Content-Type": "application/json", + }, + stream=True, + timeout=TIMEOUT, + ) + + ctype = resp.headers.get("Content-Type", "") + print(f"\nHTTP {resp.status_code} Content-Type: {ctype}\n") + + if resp.status_code != 200: + print(resp.text[:500]) + return None + + if "text/event-stream" not in ctype: + raw = resp.content + diagnose_empty_response(resp) + if raw: + for evt in parse_sse_text(raw.decode("utf-8", errors="ignore")): + print("[parsed]", evt) + return None + + session_out: Optional[str] = session_id + full_reply: List[str] = [] + has_content = False + event_count = 0 + + print("--- 流式输出 ---") + for evt in parse_sse_stream(resp): + event_count += 1 + etype = evt.get("type") + + if etype == "meta": + session_out = evt.get("session_id") or session_out + print(f"[meta] session_id={session_out} model={evt.get('model')}") + continue + + if etype == "content": + piece = evt.get("content") or "" + has_content = True + full_reply.append(piece) + print(piece, end="", flush=True) + continue + + if etype == "done": + session_out = evt.get("session_id") or session_out + reply = evt.get("reply") or "" + print(f"\n\n[done] session_id={session_out}") + print(f"[done] reply 长度={len(reply)}") + if reply and not has_content: + print(reply) + continue + + if etype == "error": + print(f"\n[error] {evt.get('msg')}") + return session_out + + print(f"\n[unknown] {evt}") + + print("\n--- 结束 ---") + if event_count == 0: + diagnose_empty_response(resp) + elif full_reply: + joined = "".join(full_reply) + print(f"拼接回复({len(joined)}字): {joined[:300]}...") + return session_out + + +def main() -> int: + if sys.platform == "win32": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + parser = argparse.ArgumentParser(description="chat_send_stream.dspy SSE 测试") + parser.add_argument("--session-id", help="续聊会话 ID") + parser.add_argument("--message", "-m", help="覆盖默认 message") + parser.add_argument("--twice", action="store_true", help="同一会话连发两条") + args = parser.parse_args() + + sid = test_chat_send_stream(session_id=args.session_id, message=args.message) + if sid is None: + return 1 + + if args.twice and sid: + print("\n" + "=" * 60) + print("第二轮(多轮续聊)") + sid2 = test_chat_send_stream( + session_id=sid, + message=args.message or "继续,用一句话总结上面内容", + ) + if sid2 is None: + return 1 + + if sid: + print(f"\n提示: 续聊 python test_demo.py --session-id {sid}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/b/customer/forgotPassword.dspy b/b/customer/forgotPassword.dspy new file mode 100644 index 0000000..b12e8b5 --- /dev/null +++ b/b/customer/forgotPassword.dspy @@ -0,0 +1,70 @@ +async def forgotPassword(ns): + """ + 忘记密码:校验短信验证码后重置密码。 + + 参数: + id (str) 用户ID(找回验证码接口返回的 userid) + password (str) 新密码 + codeid (str) 验证码ID + vcode (str) 验证码 + + 也可传 mobile 或 username 定位用户(未传 id 时)。 + """ + import re + import traceback + + if not ns.get('password'): + return {'status': False, 'msg': '新密码不能为空'} + if len(ns.get('password')) < 8 or not re.search(r'[a-zA-Z]', ns.get('password')) or not re.search(r'[0-9]', ns.get('password')): + return {'status': False, 'msg': '密码至少8位,包含大小写字母、特殊字符、数字'} + if not ns.get('codeid'): + return {'status': False, 'msg': '验证码ID不能为空'} + if not ns.get('vcode'): + return {'status': False, 'msg': '验证码不能为空'} + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + try: + code = await sor.R('validatecode', {'id': ns.get('codeid'), 'vcode': ns.get('vcode')}) + if code: + create_at = code[0]['create_at'] + now = datetime.datetime.now() + create_at_dt = datetime.datetime.strptime(create_at, "%Y-%m-%d %H:%M:%S") + if (now - create_at_dt).seconds > 500: + return {'status': False, 'msg': '验证码过期'} + else: + return {'status': False, 'msg': '验证码不正确'} + + user = None + if ns.get('id'): + users = await sor.R('users', {'id': ns.get('id'), 'del_flg': '0'}) + if users: + user = users[0] + elif ns.get('mobile'): + users = await sor.R('users', {'mobile': ns.get('mobile'), 'del_flg': '0'}) + if users: + user = users[0] + elif ns.get('username'): + users = await sor.R('users', {'username': ns.get('username'), 'del_flg': '0'}) + if not users: + users = await sor.R('users', {'mobile': ns.get('username'), 'del_flg': '0'}) + if users: + user = users[0] + else: + return {'status': False, 'msg': '用户标识不能为空'} + + if not user: + return {'status': False, 'msg': '用户不存在'} + + new_password = password_encode(ns['password']) + update_sql = """UPDATE users SET password = '%s' WHERE id = '%s';""" % (new_password, user['id']) + await sor.sqlExe(update_sql, {}) + return {'status': True, 'msg': '密码重置成功'} + except Exception as error: + debug(f"forgotPassword 错误: {error}") + debug(f"forgotPassword 错误堆栈: {traceback.format_exc()}") + return {'status': False, 'msg': '密码重置失败, %s' % str(error)} + + +ret = await forgotPassword(params_kw) +return ret diff --git a/b/customer/registerUser.dspy b/b/customer/registerUser.dspy index 3a97735..7fe3897 100644 --- a/b/customer/registerUser.dspy +++ b/b/customer/registerUser.dspy @@ -1,7 +1,118 @@ +async def sync_cn_ai_user(userid=None, orgid=None, username=None, name=None, email=None): + import aiohttp + debug(f"sync_cn_ai_user同步用户: {userid}, {orgid}, {username}, {name}, {email}") + # 目标URL + # domain 从数据库params表中获取到pname=cntoai_domain的pvalue值 + domain = None + db = DBPools() + async with db.sqlorContext('kboss') as sor: + domain = await sor.R('params', {'pname': 'cntoai_domain'}) + if domain: + domain = domain[0]['pvalue'] + else: + debug(f"sync_cn_ai_user未找到域名") + return { + 'status': False, + 'msg': '未找到域名' + } + already_sync_user_key = await sor.R('params', {'pname': 'cntoai_already_sync_user_key'}) + if already_sync_user_key: + already_sync_user_key = already_sync_user_key[0]['pvalue'] + else: + debug(f"sync_cn_ai_user未找到已同步用户key") + return { + 'status': False, + 'msg': '未找到已同步用户key' + } + already_sync_user_dappid = await sor.R('params', {'pname': 'cntoai_already_sync_user_dappid'}) + if already_sync_user_dappid: + already_sync_user_dappid = already_sync_user_dappid[0]['pvalue'] + else: + debug(f"sync_cn_ai_user未找到已同步用户dappid") + return { + 'status': False, + 'msg': '未找到已同步用户dappid' + } + + url = f"{domain}/rbac/usersync" + + # 请求头 + headers = { + "Content-Type": "application/json", + "Authorization": "Bearer %s" % already_sync_user_key + } + + # 请求体数据 + payload = { + "action": "single", + "dappid": already_sync_user_dappid, + "user": { + "id": userid, + "orgid": orgid, + "username": username, + "name": name, + "email": email + } + } + + try: + # 创建一个异步会话 + result_sysnc = None + async with aiohttp.ClientSession() as session: + # 发送POST请求 + async with session.post(url, headers=headers, data=json.dumps(payload)) as response: + # 打印响应状态码 + debug(f"sync_cn_ai_user状态码: {response.status}") + result_sysnc = await response.json() + + if not result_sysnc.get('status') == 'success': + debug(f"sync_cn_ai_user同步用户失败: {result_sysnc}") + return { + 'status': False + } + + db = DBPools() + async with db.sqlorContext('kboss') as sor: + # user_api_keys表格 userid/opc_apikey + # 首先判断apikey是否存在 + apikey = result_sysnc['data'][0].get('apikey') + appid = result_sysnc['data'][0].get('appid') + secretkey = result_sysnc['data'][0].get('secretkey') + + records = await sor.R('user_api_keys', {'opc_apikey': apikey}) + if records: + debug(f"sync_cn_ai_user用户{payload['user']['id']}已存在") + return { + 'status': False, + 'msg': '用户opc_apikey已存在' + } + await sor.C('user_api_keys', { + 'userid': userid, + 'opc_apikey': apikey, + 'appid': appid, + 'secretkey': secretkey, + 'action': 'sync', + 'expire_time': None, + }) + + debug(f"sync_cn_ai_user用户{payload['user']['id']}同步成功") + return { + 'status': True, + 'msg': '用户同步成功' + } + + except Exception as e: + debug(f"sync_cn_ai_user{userid}同步用户失败: {e}") + return { + 'status': False, + 'msg': f"sync_cn_ai_user{userid}同步用户失败: {e}" + } + async def registerUser(ns): """ 用户注册 """ + import re db = DBPools() async with db.sqlorContext('kboss') as sor: if ns: @@ -27,7 +138,7 @@ async def registerUser(ns): if ns.get('password'): # 至少8位,包含大小写字母、特殊字符、数字 - if len(ns.get('password')) < 8 or not re.search(r'[a-zA-Z]', ns.get('password')) or not re.search(r'[0-9]', ns.get('password')) or not re.search(r'[!@#$%^&*()_+{}|:"<>?]', ns.get('password')): + if len(ns.get('password')) < 8 or not re.search(r'[a-zA-Z]', ns.get('password')) or not re.search(r'[0-9]', ns.get('password')): return {'status': False, 'msg': '密码至少8位,包含大小写字母、特殊字符、数字'} if not ns.get('codeid'): @@ -176,6 +287,10 @@ async def registerUser(ns): ns['customerid'] = org_id await sor.C('customer', ns) await openCustomerAccounts(sor, org[0]['id'], org_id) + + # 同步用户 + await sync_cn_ai_user(userid=userid, orgid=ns_org['id'], username=ns['username'], name=ns['username']) + return {'status': True, 'msg': '注册成功'} except Exception as error: # raise error diff --git a/b/product/get_firstpage_product_tree.dspy b/b/product/get_firstpage_product_tree.dspy index 5e07266..b7fab08 100644 --- a/b/product/get_firstpage_product_tree.dspy +++ b/b/product/get_firstpage_product_tree.dspy @@ -539,11 +539,17 @@ jiajie_ali_products = [ ] async def get_firstpage_product_tree(ns={}): + token_market = await path_call('../cntoai/model_management_customer_search.dspy', ns) + # if token_market.get('status'): + # token_market = token_market.get('data') + # else: + # token_market = None + data = { "product_service": [ { "id": "1", - "firTitle": "云", + "firTitle": "基础云", "secMenu": [ { "id": "10", @@ -576,44 +582,79 @@ async def get_firstpage_product_tree(ns={}): ] }, { - 'id': "2", 'firTitle': "算", 'secMenu': [ - { - 'id': '21', 'secTitle': '智算', 'thrMenu': [ + 'id': "2", 'firTitle': "TOKEN市集", 'secMenu': [ { - 'id': '211', - 'thrTitle': None, - 'value': [#{'id': '2111', 'name': '容器云'}, - {'id': '2113', 'name': '裸金属'}, - #{'id': '2114', 'name': '裸金属-910B'}, - {'id': '2115', 'name': '一体机-昆仑芯'}, - {'id': '2112', 'name': '一体机-天数智芯'},] + # 'id': '21', 'secTitle': '智算', 'thrMenu': [ + # { + # 'id': '211', + # 'thrTitle': None, + # 'value': [#{'id': '2111', 'name': '容器云'}, + # {'id': '2113', 'name': '裸金属'}, + # #{'id': '2114', 'name': '裸金属-910B'}, + # {'id': '2115', 'name': '一体机-昆仑芯'}, + # {'id': '2112', 'name': '一体机-天数智芯'},] + # }, + # ], }, ], - }, - ] + 'token_market': token_market }, { - "id": "3", - "firTitle": "网", - "secMenu": [ - { - "id": "31", - "secTitle": "算力网络", - "thrMenu": [ - { - "id": "311", - "thrTitle": None, - "value": [{'id': '3111', 'name': '互联网专线'}, - {'id': '3121', 'name': 'SDWAN'}, - {'id': '3131', 'name': 'DCI'}, - {'id': '3141', 'name': 'AI专线'} - ] - } - ] - }, + 'id': "2", 'firTitle': "元境", 'secMenu': [ + # { + # 'id': '21', 'secTitle': '智算', 'thrMenu': [ + # { + # 'id': '211', + # 'thrTitle': None, + # 'value': [#{'id': '2111', 'name': '容器云'}, + # {'id': '2113', 'name': '裸金属'}, + # #{'id': '2114', 'name': '裸金属-910B'}, + # {'id': '2115', 'name': '一体机-昆仑芯'}, + # {'id': '2112', 'name': '一体机-天数智芯'},] + # }, + # ], + # }, ] }, # { + # 'id': "2", 'firTitle': "算", 'secMenu': [ + # { + # 'id': '21', 'secTitle': '智算', 'thrMenu': [ + # { + # 'id': '211', + # 'thrTitle': None, + # 'value': [#{'id': '2111', 'name': '容器云'}, + # {'id': '2113', 'name': '裸金属'}, + # #{'id': '2114', 'name': '裸金属-910B'}, + # {'id': '2115', 'name': '一体机-昆仑芯'}, + # {'id': '2112', 'name': '一体机-天数智芯'},] + # }, + # ], + # }, + # ] + # }, + # { + # "id": "3", + # "firTitle": "网", + # "secMenu": [ + # { + # "id": "31", + # "secTitle": "算力网络", + # "thrMenu": [ + # { + # "id": "311", + # "thrTitle": None, + # "value": [{'id': '3111', 'name': '互联网专线'}, + # {'id': '3121', 'name': 'SDWAN'}, + # {'id': '3131', 'name': 'DCI'}, + # {'id': '3141', 'name': 'AI专线'} + # ] + # } + # ] + # }, + # ] + # }, + # { # "id": "4", # "firTitle": "模型", # "secMenu": [] @@ -623,44 +664,44 @@ async def get_firstpage_product_tree(ns={}): # "firTitle": "服务", # "secMenu": [] # }, - { - "id": "6", - "firTitle": "用", - "secMenu": [ - { - "id": "61", - "secTitle": "AI应用", - "thrMenu": [ - { - "id": "611", - "thrTitle": "智慧医疗", - "value": [ - { - "id": "6111", - "name": "灵医智能体" - } - ] - }, - { - "id": "612", - "thrTitle": "智慧客服", - "value": [ - { - "id": "6112", - "name": "客悦·智能客服" - } - ] - }, - ] - }, - ] - } + # { + # "id": "6", + # "firTitle": "用", + # "secMenu": [ + # { + # "id": "61", + # "secTitle": "AI应用", + # "thrMenu": [ + # { + # "id": "611", + # "thrTitle": "智慧医疗", + # "value": [ + # { + # "id": "6111", + # "name": "灵医智能体" + # } + # ] + # }, + # { + # "id": "612", + # "thrTitle": "智慧客服", + # "value": [ + # { + # "id": "6112", + # "name": "客悦·智能客服" + # } + # ] + # }, + # ] + # }, + # ] + # } ] } db = DBPools() async with db.sqlorContext('kboss') as sor: try: - if ns.get('url_link') and ('kaiyuancloud' in ns.get('url_link') or 'opencomputing' in ns.get('url_link')): + if ns.get('url_link') and ('kaiyuancloud' in ns.get('url_link') or 'opencomputing' in ns.get('url_link') or 'ncmatch' in ns.get('url_link')): data_baidu = { "id": "12", "secTitle": "阿里云", diff --git a/b/user/mobilecode.dspy b/b/user/mobilecode.dspy index 5c23f9a..3bb2a31 100644 --- a/b/user/mobilecode.dspy +++ b/b/user/mobilecode.dspy @@ -112,6 +112,18 @@ async def mobilecode(ns): return {'status': False, 'msg': '发送失败'} else: return {'status': False, 'action': 'redirect', 'msg': '用户未注册, 请到注册页面注册'} + + # 忘记密码逻辑:检查手机号是否存在 + elif action_type == 'forgotpassword': + if len(userreacs) >= 1: + code = await generate_vcode() + nss = await send_vcode(userreacs[0]['mobile'], '用户注册登录验证', {'SMSvCode': code.get('vcode')}) + if nss['status']: + return {'status': True, 'msg': '验证码发送成功', 'codeid': code.get('id')} + else: + return {'status': False, 'msg': '发送失败'} + else: + return {'status': False, 'action': 'redirect', 'msg': '用户未注册, 请到注册页面注册'} # 原有逻辑:如果没有指定action_type,保持原有逻辑 else: diff --git a/f/web-kboss/src/api/gotoYuanJing.js b/f/web-kboss/src/api/gotoYuanJing.js new file mode 100644 index 0000000..ee75f2b --- /dev/null +++ b/f/web-kboss/src/api/gotoYuanJing.js @@ -0,0 +1,9 @@ +import request from '@/utils/request' +// 跳转远景 +export function gotoYuanJingAPI(data) { + return request({ + url: `cntoai/get_deerer_header.dspy`, + method: 'get', + params: data + }) +} \ No newline at end of file diff --git a/f/web-kboss/src/api/login.js b/f/web-kboss/src/api/login.js index 5f275bb..726a76c 100644 --- a/f/web-kboss/src/api/login.js +++ b/f/web-kboss/src/api/login.js @@ -72,7 +72,7 @@ export function retrieveCodeAPI(data) { //重置密码 export function getPasswordCodeAPI(params) { return request({ - url: `/user/getretrieve${suffix}`, + url: `/customer/forgotPassword${suffix}`, method: 'get', params: params }) @@ -283,3 +283,13 @@ export function register(data) { }) } + + +// 新忘记密码 +export function newForgotPassword(data) { + return request({ + url: `/customer/forgotPassword.dspy`, + method: 'post', + data, + }) +} \ No newline at end of file diff --git a/f/web-kboss/src/api/model/model.js b/f/web-kboss/src/api/model/model.js new file mode 100644 index 0000000..ba45ad7 --- /dev/null +++ b/f/web-kboss/src/api/model/model.js @@ -0,0 +1,115 @@ +import request from "@/utils/request"; +// 获取模型列表 +export const reqModelList = (params = {}) => { + return request({ + url: 'cntoai/model_management_search.dspy', + method: 'get', + params + }) +} +// 上架 +export const reqModelUp = (id) => { + return request({ + url: '/cntoai/model_management_list.dspy', + method: 'get', + params: { id } + }) +} +// 下架 +export const reqModelDown = (id) => { + return request({ + url: '/cntoai/model_management_unlist.dspy', + method: 'get', + params: { id } + }) +} +// 模型详情 +export const reqModelDetail = (id) => { + return request({ + url: '/cntoai/model_management_detail.dspy', + method: 'get', + params: { id } + }) +} +// 编辑模型 +export const reqModelEdit = (data) => { + return request({ + url: '/cntoai/model_management_update.dspy', + method: 'get', + params: data + }) +} +// 置顶 +export const reqModelTop = (id) => { + return request({ + url: '/cntoai/model_management_pin_top.dspy', + method: 'get', + params: { id } + }) +} +// 下移 +export const reqModelBottom = (id) => { + return request({ + url: '/cntoai/model_management_move_down.dspy', + method: 'get', + params: { id } + }) +} + +// apikey列表 +export const reqApikeyList = (params = {}) => { + return request({ + url: '/cntoai/get_model_apikey.dspy', + method: 'get', + params + }) +} +// 创建apikey +export const reqCreateApikey = (params = {}) => { + return request({ + url: '/cntoai/create_model_apikey.dspy', + method: 'get', + params + }) +} + +// 获取模型api文档 +export const reqModelApiDocument = (params = {}) => { + return request({ + url: '/cntoai/get_model_api_doc.dspy', + method: 'get', + params + }) +} +//模型体验多轮会话 +export const reqModelExperienceMultiRound = (params = {}) => { + return request({ + url: '/cntoai/chat_send_stream.dspy', + method: 'get', + params + }) +} +// 左侧历史对话 +export const reqModelExperienceLeftHistory = (params = {}) => { + return request({ + url: '/cntoai/chat_session_list.dspy', + method: 'get', + params + }) +} +// 历史对话信息 +export const reqModelExperienceHistoryInfo = (params = {}) => { + return request({ + url: '/cntoai/chat_session_messages.dspy', + method: 'get', + params + }) +} +// 删除历史对话 +export const reqModelExperienceDeleteHistory = (params = {}) => { + return request({ + url: '/cntoai/chat_session_delete.dspy', + method: 'get', + params + }) +} \ No newline at end of file diff --git a/f/web-kboss/src/api/newHome.js b/f/web-kboss/src/api/newHome.js index a5070db..7165756 100644 --- a/f/web-kboss/src/api/newHome.js +++ b/f/web-kboss/src/api/newHome.js @@ -101,3 +101,12 @@ export const todoCount = () => { method: 'post', }) } + + +// 获取token市集 +export const reqTokenMarket = () => { + return request({ + url: '/cntoai/model_management_customer_search.dspy', + method: 'get', + }) +} \ No newline at end of file diff --git a/f/web-kboss/src/components/modelManagement/ListingConfirmDialog.vue b/f/web-kboss/src/components/modelManagement/ListingConfirmDialog.vue new file mode 100644 index 0000000..52bc09a --- /dev/null +++ b/f/web-kboss/src/components/modelManagement/ListingConfirmDialog.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/f/web-kboss/src/components/modelManagement/ModelDetailDialog.vue b/f/web-kboss/src/components/modelManagement/ModelDetailDialog.vue new file mode 100644 index 0000000..d568b0f --- /dev/null +++ b/f/web-kboss/src/components/modelManagement/ModelDetailDialog.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/f/web-kboss/src/components/modelManagement/ModelFilter.vue b/f/web-kboss/src/components/modelManagement/ModelFilter.vue new file mode 100644 index 0000000..df6ade8 --- /dev/null +++ b/f/web-kboss/src/components/modelManagement/ModelFilter.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/f/web-kboss/src/components/modelManagement/ModelStats.vue b/f/web-kboss/src/components/modelManagement/ModelStats.vue new file mode 100644 index 0000000..4a8f9d2 --- /dev/null +++ b/f/web-kboss/src/components/modelManagement/ModelStats.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/f/web-kboss/src/layout/components/Navbar.vue b/f/web-kboss/src/layout/components/Navbar.vue index 6b32943..e8999f1 100644 --- a/f/web-kboss/src/layout/components/Navbar.vue +++ b/f/web-kboss/src/layout/components/Navbar.vue @@ -731,11 +731,17 @@ export default { }, async logout() { store.commit('tagsView/resetBreadcrumbState'); + store.commit('permission/RESET_ROUTES'); sessionStorage.removeItem("auths"); sessionStorage.removeItem("routes"); sessionStorage.removeItem("user"); sessionStorage.removeItem("userId"); sessionStorage.removeItem("org_type") + sessionStorage.removeItem("userType"); + sessionStorage.removeItem("orgType"); + sessionStorage.removeItem("roles"); + sessionStorage.removeItem("juese"); + sessionStorage.removeItem("jueseNew"); localStorage.removeItem('userId') localStorage.removeItem("auths"); localStorage.removeItem("routes"); @@ -752,10 +758,16 @@ export default { let url = window.location.href; await this.$router.push(`/login`); store.commit('tagsView/resetBreadcrumbState'); + store.commit('permission/RESET_ROUTES'); sessionStorage.removeItem("auths"); sessionStorage.removeItem("routes"); sessionStorage.removeItem("user"); sessionStorage.removeItem("userId"); + sessionStorage.removeItem("userType"); + sessionStorage.removeItem("orgType"); + sessionStorage.removeItem("roles"); + sessionStorage.removeItem("juese"); + sessionStorage.removeItem("jueseNew"); }, changeColor() { this.dialogFormVisible = false diff --git a/f/web-kboss/src/layout/components/Sidebar/SidebarItem.vue b/f/web-kboss/src/layout/components/Sidebar/SidebarItem.vue index da82b10..37615ef 100644 --- a/f/web-kboss/src/layout/components/Sidebar/SidebarItem.vue +++ b/f/web-kboss/src/layout/components/Sidebar/SidebarItem.vue @@ -97,12 +97,12 @@ export default { // 给嵌套菜单添加左边距 ::v-deep .nest-menu { .el-menu-item { - padding-left: 60px !important; // 或者您想要的任何值,比如10px + padding-left: 42px !important; // 子菜单稍微缩进,同时保留蓝底圆角选中态 } // 如果还有更深层的嵌套,可以继续设置 .nest-menu .el-menu-item { - padding-left: 100px !important; + padding-left: 64px !important; } } } diff --git a/f/web-kboss/src/layout/components/Sidebar/index.vue b/f/web-kboss/src/layout/components/Sidebar/index.vue index 9763d74..a977547 100644 --- a/f/web-kboss/src/layout/components/Sidebar/index.vue +++ b/f/web-kboss/src/layout/components/Sidebar/index.vue @@ -11,7 +11,7 @@ :text-color="variables.menuText" :unique-opened="true" :active-text-color="variables.menuActiveText" - :collapse-transition="false" + :collapse-transition="true" :default-active="activeMenu" mode="vertical" class="el-menu-vertical" @@ -25,6 +25,14 @@ /> + @@ -72,6 +80,12 @@ export default { mounted() { console.log("Sidebar mounted - 权限路由:", this.permissionRoutes); + }, + + methods: { + toggleSideBar() { + this.$store.dispatch("app/toggleSideBar"); + } } }; @@ -88,6 +102,8 @@ export default { display: flex; flex-direction: column; box-sizing: border-box; + position: relative; + transition: width 0.25s ease; } .menu-scroll-container { @@ -107,6 +123,36 @@ export default { } } + .sidebar-collapse-btn { + position: absolute; + right: 16px; + bottom: 18px; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + color: #8a94a6; + font-size: 18px; + cursor: pointer; + background: #ffffff; + border: 1px solid #eef2f7; + border-radius: 50%; + box-shadow: 0 8px 22px rgba(31, 45, 61, 0.12); + transition: all 0.25s ease; + + &:hover { + color: #1e6fff; + transform: translateY(-2px); + box-shadow: 0 12px 26px rgba(30, 111, 255, 0.18); + } + + &.collapsed { + right: 11px; + } + } + // 更具体的选择器 ::v-deep .el-menu-vertical { border: none; @@ -117,9 +163,37 @@ export default { .el-submenu__title, .el-menu-item { + height: 56px; + line-height: 56px; + margin: 6px 14px; + padding: 0 18px !important; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + border-radius: 12px; + transition: all 0.25s ease; + } + + .el-submenu__title span, + .el-menu-item span { + transition: opacity 0.2s ease, transform 0.2s ease; + } + + .el-submenu__title:hover, + .el-menu-item:not(.is-active):hover { + color: #1e6fff !important; + background: #f4f8ff !important; + } + + .el-menu-item.is-active { + color: #ffffff !important; + background: linear-gradient(135deg, #1e6fff, #5d8dff) !important; + + } + + .el-menu-item.is-active i, + .el-menu-item.is-active span { + color: #ffffff !important; } // 子菜单容器 @@ -128,23 +202,18 @@ export default { .el-menu-item { // 激活的子菜单项 &.is-active { - background-color: #d7dafd !important; - color: #296ad9 !important; + background: linear-gradient(135deg, #1e6fff, #5d8dff) !important; + color: #ffffff !important; + &:before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 3px; - background-color: #296ad9; + display: none; } } // 非激活状态的悬停效果 &:not(.is-active):hover { - background-color: #f5f7fa !important; + background-color: #f4f8ff !important; } } } @@ -157,16 +226,24 @@ export default { .el-submenu__title, .el-menu-item { + margin: 6px 8px; + padding: 0 !important; text-overflow: clip; justify-content: center; } + .el-submenu__title span, + .el-menu-item span { + opacity: 0; + transform: translateX(-6px); + } + // 折叠状态下的子菜单激活样式 .el-menu--popup { .el-menu-item { &.is-active { - background-color: #f5f7fa !important; - color: #296ad9 !important; + color: #ffffff !important; + background: linear-gradient(135deg, #1e6fff 0%, #244fbd 100%) !important; } } } diff --git a/f/web-kboss/src/permission.js b/f/web-kboss/src/permission.js index 891d831..7420a79 100644 --- a/f/web-kboss/src/permission.js +++ b/f/web-kboss/src/permission.js @@ -11,7 +11,7 @@ import {getHomePath} from "@/views/setting/tools"; NProgress.configure({showSpinner: false}); // NProgress Configuration -const whiteList = ["/login", "/homePage", "/registrationPage", "/shoppingCart", "/homePageImage","/h5HomePage",'/H5about','/modelProductDetail','/ncmatchHome']; // no redirect whitelist +const whiteList = ["product","/login", "/homePage", "/registrationPage", "/shoppingCart", "/homePageImage","/h5HomePage",'/H5about','/modelProductDetail','/ncmatchHome']; // no redirect whitelist // 获取用户代理字符串 const userAgent = window.navigator.userAgent; diff --git a/f/web-kboss/src/router/index.js b/f/web-kboss/src/router/index.js index cc39ae4..55b152d 100644 --- a/f/web-kboss/src/router/index.js +++ b/f/web-kboss/src/router/index.js @@ -404,11 +404,36 @@ export const constantRoutes = [ * 需要根据用户角色动态加载的路由 */ export const asyncRoutes = [ + // 运营——模型管理 + { + path: "/modelManagement", + component: Layout, + meta: { + // title 是菜单上显示的文字,fullPath 用来和后端权限 path 对权限。 + title: "模型管理", + fullPath: "/modelManagement", + noCache: true, + // icon 是左侧菜单图标,roles 限制只有运营角色能看到。 + icon: "el-icon-cpu", + roles: ["运营"] + }, + children: [ + { + path: "", + component: () => import('@/views/modelManagement/modelManagement.vue'), + name: 'modelManagement', + meta: { + title: "模型管理", + fullPath: "/modelManagement", + noCache: true, + roles: ["运营"] + } + }, + ] + }, - - // 全部产品 - 一级菜单 - // 全部产品 - 一级菜单(无子路由) + // token市集 - 一级菜单(所有登录用户都能看到) { path: "/product", component: Layout, @@ -416,7 +441,7 @@ export const asyncRoutes = [ title: "全部产品", fullPath: "/product", noCache: true, - icon: "el-icon-goods" + icon: "el-icon-coin" }, children: [ { @@ -431,6 +456,66 @@ export const asyncRoutes = [ }, ] }, + // 令牌管理 - 一级菜单(所有登录用户都能看到) + { + path: "/tokenManagement", + component: Layout, + meta: { + title: "令牌管理", + fullPath: "/tokenManagement", + noCache: true, + icon: "el-icon-key" + }, + children: [ + { + path: "", + component: () => import('@/views/tokenManagement/index.vue'), + name: 'TokenManagement', + meta: { + title: "令牌管理", + fullPath: "/tokenManagement", + noCache: true, + icon: "el-icon-key" + } + }, + ] + }, + // 模型体验 + { + path: "/modelExperience", + component: () => import('@/views/modelManagement/Experience.vue'), + hidden: true, + name: 'modelExperience', + meta: { + title: "模型体验", + fullPath: "/modelExperience", + noCache: true + }, + }, + // 模型详情 + { + path: "/modelDetail", + component: () => import('@/views/modelManagement/ModelDetail.vue'), + hidden: true, + name: 'modelDetail', + meta: { + title: "模型详情", + fullPath: "/modelDetail", + noCache: true + }, + }, + // API文档 + { + path: "/modelApiDocument", + component: () => import('@/views/modelManagement/ApiDocument.vue'), + hidden: true, + name: 'modelApiDocument', + meta: { + title: "API文档", + fullPath: "/modelApiDocument", + noCache: true + }, + }, { path: "/overview", component: Layout, @@ -453,6 +538,32 @@ export const asyncRoutes = [ } ] }, + // 运营——运营报表 + { + path: "/operationReport", + component: Layout, + meta: { + title: "运营报表", + fullPath: "/operationReport", + noCache: true, + icon: "el-icon-data-analysis", + roles: ["运营"] + }, + children: [ + { + path: "", + component: () => import('@/views/operation/operationReport/index.vue'), + name: 'operationReport', + meta: { + title: "运营报表", + fullPath: "/operationReport", + noCache: true, + icon: "el-icon-data-analysis", + roles: ["运营"] + } + } + ] + }, { path: "/orderManagement", @@ -565,13 +676,15 @@ export const asyncRoutes = [ path: "/consultingMangement", name: 'ConsultingMangement', component: Layout, - meta: { title: "咨询表单", fullPath: "/consultingMangement", noCache: true, icon: "el-icon-s-platform" }, + // 咨询表单是表单/订单类入口,所以菜单图标用 el-icon-s-order。 + meta: { title: "咨询表单", fullPath: "/consultingMangement", noCache: true, icon: "el-icon-s-order" }, children: [ { path: "index", component: () => import('@/views/operation/consultingMangement/index.vue'), name: 'ConsultingMangement', - meta: { title: "咨询表单", fullPath: "/consultingMangement/index", noCache: true, icon: "el-icon-s-platform" }, + // 子路由也带 icon,单子菜单折叠成一级菜单时能继续显示图标。 + meta: { title: "咨询表单", fullPath: "/consultingMangement/index", noCache: true, icon: "el-icon-s-order" }, } ] }, @@ -1180,7 +1293,8 @@ export const asyncRoutes = [ component: Layout, name: "qualificationReview", redirect: "/qualificationReview/index", - meta: { fullPath: "/qualificationReview", title: "资质审核", noCache: true, icon: 'el-icon-s-home' }, + // 资质审核是审核/校验类菜单,所以用 el-icon-s-check。 + meta: { fullPath: "/qualificationReview", title: "资质审核", noCache: true, icon: 'el-icon-s-check' }, children: [ { path: "noApproveInfo", @@ -1203,7 +1317,8 @@ export const asyncRoutes = [ component: Layout, name: "approveMangement", redirect: "/approveMangement/index", - meta: { fullPath: "/approveMangement", title: "供需审核", noCache: true, icon: 'el-icon-s-home' }, + // 供需审核表示供给和需求两边协作审核,所以用 el-icon-s-cooperation。 + meta: { fullPath: "/approveMangement", title: "供需审核", noCache: true, icon: 'el-icon-s-cooperation' }, children: [ { path: "pendingPro", @@ -1263,12 +1378,14 @@ export const asyncRoutes = [ }, ] }, + { path: "/menuMangement", component: Layout, name: "menuMangement", redirect: "/menuMangement/index", - meta: { fullPath: "/menuMangement", title: "菜单管理", noCache: true, icon: 'el-icon-s-home' }, + // 菜单管理就是维护菜单配置,用 Element UI 的菜单图标。 + meta: { fullPath: "/menuMangement", title: "菜单管理", noCache: true, icon: 'el-icon-menu' }, children: [ { path: "index", @@ -1435,7 +1552,8 @@ export const asyncRoutes = [ { path: "/operation", component: Layout, redirect: "/operation/supplierManagement", meta: { - title: "运营", icon: "el-icon-s-tools", noCache: true, fullPath: "/operation", + // 运营是运营后台入口,用操作/运营类图标。 + title: "运营", icon: "el-icon-s-operation", noCache: true, fullPath: "/operation", }, children: [ { @@ -1559,7 +1677,7 @@ export const asyncRoutes = [ hidden: true, path: "colony", - title: "管理集群", + title: "管理集群", component: () => import("@/views/operation/computingCenterManagement/colony/index.vue"), name: "supplierManagement", meta: { diff --git a/f/web-kboss/src/store/modules/permission.js b/f/web-kboss/src/store/modules/permission.js index d302399..b59a9f4 100644 --- a/f/web-kboss/src/store/modules/permission.js +++ b/f/web-kboss/src/store/modules/permission.js @@ -1,24 +1,197 @@ -// permission.js - 修改后的完整代码 import { asyncRoutes, constantRoutes } from "@/router"; -// 获取用户代理字符串 -const userAgent = window.navigator.userAgent; +// 用浏览器 UA 判断当前是不是手机端,后面会按 PC / 手机过滤菜单。 +const MOBILE_UA_REGEXP = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; -// 判断是否为移动设备 -const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent); +// 项目里用到的固定角色名,集中放这里,避免代码里到处写字符串。 +const CUSTOMER_ROLE = '客户'; +const OPERATION_ROLE = '运营'; -// 如果是移动设备,添加移动端首页路由和根路径重定向 +// 这个用户能看到订单管理里的特殊子菜单,比如历史订单和订单详情。 +const SPECIAL_ORDER_USER = 'ZhipuHZ'; + +// 超级管理员只放行这个一级菜单。 +const SUPER_ADMIN_ROUTE_PATH = '/superAdministrator'; + +// 所有登录用户都能访问的公共路由,不依赖后端 auths 和角色。hidden 路由不会显示在菜单里。 +const COMMON_ROUTE_PATHS = ['/product', '/tokenManagement', '/modelExperience', '/modelDetail', '/modelApiDocument']; + +// 运营角色需要额外补出来的菜单。 +const OPERATION_EXTRA_ROUTE_PATHS = ['/modelManagement', '/operationReport']; + +// 普通客户账号默认要补出来的基础菜单。 +const BASE_USER_ROUTE_PATHS = ['/orderManagement', '/resourceManagement']; + +// 客户角色额外能看到的一级菜单。 +const CUSTOMER_EXTRA_ROUTE_PATHS = [ + '/unsubscribeManagement', + '/informationPerfect', + '/rechargeManagement', + '/invoiceManagement', + '/workOrderManagement' +]; + +// 这些菜单只允许客户角色看到,非客户就算后端给了权限也不展示。 +const CUSTOMER_ONLY_ROUTE_PATHS = [ + '/overview', + ...CUSTOMER_EXTRA_ROUTE_PATHS +]; + +// 客户登录后必须能看到的入口菜单,不完全依赖后端 auths 返回。 +const CUSTOMER_ALWAYS_VISIBLE_ROUTE_PATHS = ['/overview']; + +// 订单管理里只给 SPECIAL_ORDER_USER 看的子菜单 path。 +const ORDER_CHILDREN_ONLY_FOR_SPECIAL_USER = ['HistoricalOrders', 'orderDetails']; + +const isMobile = MOBILE_UA_REGEXP.test(window.navigator.userAgent); + +// 把角色统一整理成数组,兼容 undefined、数组、逗号字符串这几种写法。 +function normalizeRoles(roles) { + if (!roles) { + return []; + } + + if (Array.isArray(roles)) { + return roles; + } + + if (typeof roles === 'string') { + return roles.split(',').filter(Boolean); + } + + return []; +} + +// 从 sessionStorage 里取 roles,取不到或格式坏了就当成没有角色。 +function getSessionRoles() { + try { + return JSON.parse(sessionStorage.getItem('roles') || '[]'); + } catch (error) { + console.warn('读取 roles 失败:', error); + return []; + } +} + +// 汇总当前用户的所有角色来源:接口参数、vuex、sessionStorage、新旧字段。 +function getCurrentRoles(params, rootState) { + return [ + ...normalizeRoles(params.roles), + ...normalizeRoles(rootState.user.roles), + ...normalizeRoles(getSessionRoles()), + ...normalizeRoles(sessionStorage.getItem('jueseNew')) + ]; +} + +// 判断当前用户是不是客户角色。 +function isCustomer(userRoles = []) { + return userRoles.includes(CUSTOMER_ROLE); +} + +// 把布尔值转成更好读的设备类型,后面的判断都用 pc / mobile。 +function getDeviceType(isMobileDevice) { + return isMobileDevice ? 'mobile' : 'pc'; +} + +// 判断路由 meta.roles 是否满足。没写 roles 的路由默认所有角色都能继续往下判断。 +function hasRouteRole(route, userRoles = []) { + const routeRoles = route.meta?.roles; + + if (!routeRoles || routeRoles.length === 0) { + return true; + } + + return routeRoles.some(role => userRoles.includes(role)); +} + +// 根据设备过滤路由:手机只要手机路由,PC 不要手机专用路由。 +function isRouteAllowedByDevice(route, deviceType) { + if (deviceType === 'mobile') { + return route.meta?.isMobile || route.meta?.isMobile === true; + } + + return route.meta?.isMobile !== true; +} + +// 在一组路由里按 path 找某个路由。 +function findRouteByPath(routes, path) { + return routes.find(route => route.path === path); +} + +// 后端 auths 里的 path 要和路由 meta.fullPath 对上,对上才算有权限。 +function routeHasPermission(route, permissions) { + return permissions.some(permission => permission.path === route.meta?.fullPath); +} + +// 客户专属菜单要再卡一层客户角色,防止非客户误展示。 +function canShowCustomerOnlyRoute(route, userRoles) { + return !CUSTOMER_ONLY_ROUTE_PATHS.includes(route.path) || isCustomer(userRoles); +} + +// 把所有动态路由的 fullPath 收集出来。后端返回 path 为空时,表示拥有全部权限。 +function getAllRoutePermissions(routes) { + const permissions = []; + + routes.forEach(route => { + if (route.meta?.fullPath) { + permissions.push({ path: route.meta.fullPath }); + } + + if (route.children) { + permissions.push(...getAllRoutePermissions(route.children)); + } + }); + + return permissions; +} + +// 复制路由对象。这里不能用 JSON 深拷贝,因为路由里的 component 是函数,会被 JSON 丢掉。 +function cloneRoute(route) { + const clonedRoute = { ...route }; + + if (route.meta) { + clonedRoute.meta = { ...route.meta }; + } + + if (route.children) { + clonedRoute.children = route.children.map(cloneRoute); + } + + return clonedRoute; +} + +// 根据 path 列表批量找到对应路由,没找到的自动过滤掉。 +function getRoutesByPath(routes, paths) { + return paths + .map(path => findRouteByPath(routes, path)) + .filter(Boolean); +} + +// 判断这个一级路由是不是已经加过了,避免菜单重复出现。 +function shouldAppendRoute(accessedRoutes, route) { + return !accessedRoutes.some(item => item.path === route.path); +} + +// 把缺少的路由补到最终菜单里,补之前会先去重并复制一份。 +function appendMissingRoutes(accessedRoutes, routesToAppend) { + routesToAppend.forEach(route => { + if (shouldAppendRoute(accessedRoutes, route)) { + accessedRoutes.push(cloneRoute(route)); + } + }); + + return accessedRoutes; +} + +// 如果是手机访问,额外把根路径导到 H5 首页,并注册 H5 首页菜单。 if (isMobile) { console.log("检测到移动设备,添加移动端路由"); - // 先添加根路径重定向到移动端首页 constantRoutes.unshift({ path: '/', redirect: '/h5HomePage', hidden: true }); - // 添加移动端首页路由 constantRoutes.push({ path: '/h5HomePage', name: 'H5HomePage', @@ -82,106 +255,165 @@ if (isMobile) { }); } -// 修复:更全面的路由过滤逻辑 +// 核心过滤函数:拿后端权限、角色和设备类型,一层层筛出最终可访问路由。 function filterAsyncRoutes(routes, permissions, userRoles = [], deviceType = 'pc') { const res = []; - // 定义需要客户角色才能访问的路由 - const customerOnlyRoutes = [ - "/product", "/overview", "/workOrderManagement", - "/unsubscribeManagement", "/informationPerfect", - "/rechargeManagement", "/invoiceManagement" - ]; - routes.forEach(route => { - // 创建路由副本 - const tmpRoute = { ...route }; + // 先复制一份,避免直接改原始 asyncRoutes。 + const tmpRoute = cloneRoute(route); - // 检查当前路由是否在权限列表中 - const hasPermission = permissions.some(p => p.path === route.meta?.fullPath); - - // 特殊处理:确保"全部产品"和"资源概览"这两个一级路由在客户角色下显示 - const isCriticalRoute = route.path === "/product" || route.path === "/overview"; - - // 检查是否为仅客户可访问的路由 - const isCustomerOnlyRoute = customerOnlyRoutes.includes(route.path); - - // 如果路由需要客户角色,但用户不是客户,则跳过 - if (isCustomerOnlyRoute && !userRoles.includes('客户')) { - return; // 跳过当前路由 + // 第一步:角色不符合,或者客户专属菜单但当前用户不是客户,直接跳过。 + if (!hasRouteRole(tmpRoute, userRoles) || !canShowCustomerOnlyRoute(route, userRoles)) { + return; } - // 新增:根据设备类型过滤路由 - if (deviceType === 'mobile' && !(route.meta?.isMobile || route.meta?.isMobile === true)) { - return; // 移动设备跳过非移动端路由 - } - if (deviceType === 'pc' && route.meta?.isMobile === true) { - return; // PC设备跳过移动端路由 + // 第二步:设备不符合也跳过,比如 PC 端不展示 H5 专用路由。 + if (!isRouteAllowedByDevice(route, deviceType)) { + return; } - // 如果当前路由有权限,则加入结果 - if (hasPermission) { + // 第三步:看后端 auths 里有没有当前路由的 fullPath。 + const hasPermission = routeHasPermission(route, permissions); + + // 第四步:客户首页入口特殊处理,客户登录后默认展示。 + const isAlwaysVisibleCustomerRoute = + CUSTOMER_ALWAYS_VISIBLE_ROUTE_PATHS.includes(route.path) && isCustomer(userRoles); + + // 有权限,或者是客户默认入口,就把这个路由放进最终菜单。 + if (hasPermission || isAlwaysVisibleCustomerRoute) { res.push(tmpRoute); - } - // 如果是关键路由且用户是客户,也要加入结果 - else if (isCriticalRoute && userRoles.includes('客户')) { - res.push(tmpRoute); - } - // 如果没有直接权限,但有子路由,递归处理子路由 - else if (tmpRoute.children) { + } else if (tmpRoute.children) { + // 父级没权限时继续看子级。只要子级有权限,父级也要保留,否则子菜单没地方挂。 const filteredChildren = filterAsyncRoutes(tmpRoute.children, permissions, userRoles, deviceType); + if (filteredChildren.length > 0) { tmpRoute.children = filteredChildren; - res.push(tmpRoute); // 即使父路由本身没有权限,只要有子路由有权限,也要保留父路由 + res.push(tmpRoute); } } - // 如果当前路由既没有权限,也没有有权限的子路由,则不添加到结果中 }); + return res; } -// 新增:为普通用户添加订单管理和资源管理路由 +// 给普通用户和客户补充固定菜单:订单、资源,以及客户专属的工单/充值/发票等。 function addUserRoutes(routes, userType, orgType, userRoles = [], deviceType = 'pc') { console.log("addUserRoutes - userType:", userType, "orgType:", orgType, "userRoles:", userRoles); const userRoutes = []; - // 修复:包含 orgType 为 2 和 3 的情况(公司客户和个人客户) - if (userType === 'user' || orgType == 2 || orgType == 3) { - const orderManagementRoute = routes.find(route => route.path === "/orderManagement"); - const resourceManagementRoute = routes.find(route => route.path === "/resourceManagement"); + // orgType 为 2 或 3 时也按客户账号处理。 + const isUserAccount = userType === 'user' || orgType == 2 || orgType == 3; - // 新增:根据设备类型过滤 - if (orderManagementRoute && (deviceType === 'pc' || orderManagementRoute.meta?.isMobile === true)) { - console.log("添加订单管理路由"); - userRoutes.push(JSON.parse(JSON.stringify(orderManagementRoute))); // 深拷贝 - } + if (isUserAccount) { + // 普通客户账号默认补订单管理和资源管理。 + const baseUserRoutes = getRoutesByPath(routes, BASE_USER_ROUTE_PATHS) + .filter(route => isRouteAllowedByDevice(route, deviceType)); - if (resourceManagementRoute && (deviceType === 'pc' || resourceManagementRoute.meta?.isMobile === true)) { - console.log("添加资源管理路由"); - userRoutes.push(JSON.parse(JSON.stringify(resourceManagementRoute))); // 深拷贝 - } + console.log("添加基础用户菜单路由:", baseUserRoutes.map(route => route.path)); + userRoutes.push(...baseUserRoutes); } - // 新增:为所有用户添加五个新的客户菜单,但只有客户角色才能看到 - const newCustomerRoutes = [ - routes.find(route => route.path === "/unsubscribeManagement"), - routes.find(route => route.path === "/informationPerfect"), - routes.find(route => route.path === "/rechargeManagement"), - routes.find(route => route.path === "/invoiceManagement"), - routes.find(route => route.path === "/workOrderManagement") - ].filter(route => { - // 过滤掉undefined,并且只有客户角色才能看到这些路由 - return route && userRoles.includes('客户') && - (deviceType === 'pc' || route.meta?.isMobile === true); - }); + if (isCustomer(userRoles)) { + // 只有客户角色才补客户专属菜单。 + const customerRoutes = getRoutesByPath(routes, CUSTOMER_EXTRA_ROUTE_PATHS) + .filter(route => isRouteAllowedByDevice(route, deviceType)); - console.log("添加新的客户菜单路由:", newCustomerRoutes.map(r => r.path)); - userRoutes.push(...newCustomerRoutes); + console.log("添加客户菜单路由:", customerRoutes.map(route => route.path)); + userRoutes.push(...customerRoutes); + } return userRoutes; } +// 运营角色额外补模型管理菜单,目前只在 PC 端展示。 +function addOperationRoutes(accessedRoutes, routes, userRoles = [], deviceType = 'pc') { + if (!userRoles.includes(OPERATION_ROLE) || deviceType !== 'pc') { + return accessedRoutes; + } + + return appendMissingRoutes(accessedRoutes, getRoutesByPath(routes, OPERATION_EXTRA_ROUTE_PATHS)); +} + +// token市集是公共菜单,所有登录用户都要能看到。 +function addCommonRoutes(accessedRoutes, routes, deviceType = 'pc') { + const commonRoutes = getRoutesByPath(routes, COMMON_ROUTE_PATHS) + .filter(route => isRouteAllowedByDevice(route, deviceType)); + + return appendMissingRoutes(accessedRoutes, commonRoutes); +} + +// 订单管理有两个特殊子菜单,只有 SPECIAL_ORDER_USER 能看到,其他用户过滤掉。 +function filterOrderChildrenByUser(routes, username) { + if (username === SPECIAL_ORDER_USER) { + console.log(`用户 ${username} 是 ${SPECIAL_ORDER_USER},保留所有订单子路由`); + return routes; + } + + return routes.map(route => { + const nextRoute = cloneRoute(route); + + // 找到订单管理后,移除特殊用户专属的子菜单。 + if (nextRoute.path === '/orderManagement' && nextRoute.children) { + console.log(`用户 ${username} 不是 ${SPECIAL_ORDER_USER},过滤订单管理子路由`); + nextRoute.children = nextRoute.children.filter(child => + !ORDER_CHILDREN_ONLY_FOR_SPECIAL_USER.includes(child.path) + ); + console.log('过滤后订单子路由:', nextRoute.children.map(child => child.path)); + } + + if (nextRoute.children) { + // 子路由里如果还有订单管理,也继续递归处理。 + nextRoute.children = filterOrderChildrenByUser(nextRoute.children, username); + } + + return nextRoute; + }); +} + +// 整理后端权限列表。如果包含空 path,就按“拥有全部动态路由权限”处理。 +function getPermissionList(auths = []) { + const permissions = JSON.parse(JSON.stringify(auths)); + const permissionPaths = permissions.map(item => item.path); + + if (permissionPaths.includes('')) { + return getAllRoutePermissions(asyncRoutes); + } + + return permissions; +} + +// 根据后端 auths 生成第一版可访问路由。没有 auths 就不展示动态菜单。 +function getAccessedRoutesByPermission(auths, userRoles, deviceType) { + if (!auths.length) { + return []; + } + + const permissions = getPermissionList(auths); + return filterAsyncRoutes(asyncRoutes, permissions, userRoles, deviceType); +} + +// 判断是不是超级管理员账号:用户名包含 admin,并且不是客户组织。 +function isSuperAdminUser(username, orgType) { + return username && username.includes('admin') && orgType != 2 && orgType != 3; +} + +// 超级管理员只拿超级管理员菜单;手机端不展示这个菜单。 +function getSuperAdminRoutes(deviceType) { + if (deviceType !== 'pc') { + return []; + } + + return getRoutesByPath(asyncRoutes, [SUPER_ADMIN_ROUTE_PATH]).map(cloneRoute); +} + +// 在已有权限菜单基础上,补充用户类型/客户角色需要固定展示的菜单。 +function addUserSpecificRoutes(accessedRoutes, userType, orgType, userRoles, deviceType) { + const userSpecificRoutes = addUserRoutes(asyncRoutes, userType, orgType, userRoles, deviceType); + return appendMissingRoutes(accessedRoutes, userSpecificRoutes); +} + const state = { routes: [], addRoutes: [], @@ -192,12 +424,19 @@ const state = { const mutations = { SET_ROUTES: (state, routes) => { console.log("MUTATION SET_ROUTES - received routes:", routes); + // addRoutes 只保存动态生成的菜单,方便 router.addRoutes 使用。 state.addRoutes = routes; sessionStorage.setItem("routes", JSON.stringify(routes)); - // 将移动端首页路由也包含在内 + // routes 是侧边栏最终读取的数据:基础路由 + 动态权限路由。 state.routes = constantRoutes.concat(routes); console.log("MUTATION SET_ROUTES - final state.routes:", state.routes); }, + RESET_ROUTES: (state) => { + // 退出登录或切换账号时,必须清掉内存里的旧菜单,否则不刷新页面会继续显示上个角色的菜单。 + state.routes = []; + state.addRoutes = []; + sessionStorage.removeItem("routes"); + }, SETUSERS: (state, user) => { state.users = user; }, @@ -226,131 +465,46 @@ const actions = { generateRoutes({ commit, rootState, state }, params) { console.log("ACTION generateRoutes - params:", params); return new Promise((resolve) => { - let accessedRoutes; - - // 从参数或sessionStorage中获取用户类型和组织类型 + // 1. 先拿到用户基础信息,优先用传进来的参数,没有就从 sessionStorage / vuex 兜底。 const userType = params.userType || sessionStorage.getItem('userType') || ''; const orgType = params.orgType || parseInt(sessionStorage.getItem('orgType')) || 0; - - // 获取用户角色(从store或sessionStorage) - const userRoles = rootState.user.roles || JSON.parse(sessionStorage.getItem('roles') || '[]'); - console.log("用户角色:", userRoles); - - // 获取用户名 const username = params.user || rootState.user.user || ''; - console.log("当前用户名:", username, "检查是否是ZhipuHZ:", username === 'ZhipuHZ'); + const userRoles = getCurrentRoles(params, rootState); + const deviceType = getDeviceType(state.isMobile); + const auths = params.auths ? JSON.parse(JSON.stringify(params.auths)) : []; - console.log("用户类型:", userType, "orgType:", orgType); + // 2. 判断是不是超级管理员,超级管理员走单独菜单逻辑。 + const isSuperAdmin = isSuperAdminUser(params.user, orgType); - // 确定设备类型 - const deviceType = state.isMobile ? 'mobile' : 'pc'; - console.log("设备类型:", deviceType); + console.log("用户角色:", userRoles); + console.log("当前用户名:", username, `检查是否是${SPECIAL_ORDER_USER}:`, username === SPECIAL_ORDER_USER); + console.log("用户类型:", userType, "orgType:", orgType, "设备类型:", deviceType); + console.log("ACTION generateRoutes - auths:", auths); - // 修复:包含 orgType 为 2 和 3 的情况 - if (params.user && params.user.includes("admin") && orgType != 2 && orgType != 3) { - // 管理员:只显示超级管理员菜单(仅PC端) - if (deviceType === 'pc') { - accessedRoutes = asyncRoutes.filter(item => item.path === '/superAdministrator'); - } else { - accessedRoutes = []; - } - } else { - const auths = params.auths ? JSON.parse(JSON.stringify(params.auths)) : []; - console.log("ACTION generateRoutes - auths:", auths); + // 3. 先生成第一版菜单:超级管理员只拿超管菜单,普通用户按后端 auths 过滤。 + let accessedRoutes = isSuperAdmin + ? getSuperAdminRoutes(deviceType) + : getAccessedRoutesByPermission(auths, userRoles, deviceType); - if (auths.length) { - // 确保 auths 中的 path 与路由 meta.fullPath 匹配 - const paths = auths.map((item) => { - return item.path; - }); - console.log("ACTION generateRoutes - paths from auths:", paths); + // 4. token市集是公共入口,所有登录用户都补上。 + accessedRoutes = addCommonRoutes(accessedRoutes, asyncRoutes, deviceType); - if (paths.includes("")) { - // 如果权限列表包含空路径,认为用户有所有权限 - accessedRoutes = asyncRoutes || []; - } else { - // 传入用户角色和设备类型 - accessedRoutes = filterAsyncRoutes(asyncRoutes, auths, userRoles, deviceType); - } - } else { - // 如果没有权限列表,不显示任何动态路由 - accessedRoutes = []; - } - - // 为普通用户添加订单管理和资源管理路由以及新的五个客户菜单 + if (!isSuperAdmin) { + // 5. 普通用户再补一些固定入口,比如订单、资源、客户专属菜单。 console.log("为用户添加特定路由"); - const userSpecificRoutes = addUserRoutes(asyncRoutes, userType, orgType, userRoles, deviceType); - - // 确保不重复添加路由,同时检查角色权限 - userSpecificRoutes.forEach(route => { - const isCustomerRoute = [ - "/workOrderManagement", "/unsubscribeManagement", "/informationPerfect", - "/rechargeManagement", "/invoiceManagement" - ].includes(route.path); - - // 如果是客户路由但用户不是客户,则不添加 - if (isCustomerRoute && !userRoles.includes('客户')) { - return; - } - - if (!accessedRoutes.some(r => r.path === route.path)) { - accessedRoutes.push(route); - } - }); - + accessedRoutes = addUserSpecificRoutes(accessedRoutes, userType, orgType, userRoles, deviceType); console.log("添加用户特定路由后的accessedRoutes:", accessedRoutes); } - // ========== 暴力过滤:直接修改 accessedRoutes ========== - // 遍历所有路由,找到 /orderManagement 路由,然后过滤它的子路由 - accessedRoutes = accessedRoutes.map(route => { - if (route.path === "/orderManagement") { - console.log("找到订单管理路由,准备过滤子路由,用户名:", username); + // 6. 运营角色额外补模型管理。 + accessedRoutes = addOperationRoutes(accessedRoutes, asyncRoutes, userRoles, deviceType); - // 创建路由副本 - const newRoute = { ...route }; - - if (newRoute.children) { - // 如果不是 ZhipuHZ 用户,过滤掉 HistoricalOrders 和 orderDetails 路由 - if (username !== 'ZhipuHZ') { - console.log(`用户 ${username} 不是 ZhipuHZ,过滤订单管理子路由`); - newRoute.children = newRoute.children.filter(child => - child.path !== 'HistoricalOrders' && child.path !== 'orderDetails' - ); - console.log(`过滤后子路由:`, newRoute.children.map(c => c.path)); - } else { - console.log(`用户 ${username} 是 ZhipuHZ,保留所有子路由`); - } - } - - return newRoute; - } - - // 对于其他路由,保持原样 - return route; - }); - - // 再次检查,确保没有遗漏的任何 orderManagement 路由 - accessedRoutes.forEach(route => { - if (route.children) { - route.children = route.children.filter(child => { - // 如果子路由是 orderManagement,也需要处理 - if (child.path === "/orderManagement") { - console.log("在子路由中找到订单管理路由,准备过滤,用户名:", username); - - if (child.children && username !== 'ZhipuHZ') { - child.children = child.children.filter(grandChild => - grandChild.path !== 'HistoricalOrders' && grandChild.path !== 'orderDetails' - ); - } - } - return true; - }); - } - }); + // 7. 最后处理订单管理里的特殊子菜单权限。 + accessedRoutes = filterOrderChildrenByUser(accessedRoutes, username); console.log("ACTION generateRoutes - 最终 calculated accessedRoutes:", accessedRoutes); + // 8. 保存到 vuex 和 sessionStorage,侧边栏会读取 state.permission.routes。 commit("SET_ROUTES", accessedRoutes); resolve(accessedRoutes); }); diff --git a/f/web-kboss/src/store/modules/user.js b/f/web-kboss/src/store/modules/user.js index 22b8fec..c91ee87 100644 --- a/f/web-kboss/src/store/modules/user.js +++ b/f/web-kboss/src/store/modules/user.js @@ -13,6 +13,13 @@ const safeToString = (value, defaultValue = '') => { return value.toString(); }; +const normalizeLoginRoles = (roles) => { + if (!roles || roles === 'None') return []; + if (Array.isArray(roles)) return roles; + if (typeof roles === 'string') return roles.split(',').filter(Boolean); + return []; +}; + // 从sessionStorage恢复状态 const getStoredState = () => { return { @@ -130,8 +137,11 @@ const actions = { // 修复:org_type 为 2 或 3 都表示客户 const userType = (org_type == 2 || org_type == 3) ? 'user' : 'admin'; - // 设置用户角色 - 如果是客户,则添加'客户'角色 - const userRoles = (org_type == 2 || org_type == 3) ? ['客户'] : ['管理员']; + // 使用接口返回的真实角色生成菜单;客户组织兜底补上“客户”角色。 + const userRoles = normalizeLoginRoles(response.roles); + if ((org_type == 2 || org_type == 3) && !userRoles.includes('客户')) { + userRoles.push('客户'); + } commit("SET_USER_TYPE", userType); // 确保 org_type 不为 undefined @@ -141,6 +151,8 @@ const actions = { console.log("登录用户类型:", userType, "org_type:", org_type, "用户角色:", userRoles); data ? commit("SET_AUTHS", data) : commit("SET_AUTHS", []); + resetRouter(); + commit("permission/RESET_ROUTES", null, { root: true }); const accessRoutes = await store.dispatch( "permission/generateRoutes", { @@ -151,7 +163,6 @@ const actions = { roles: userRoles // 新增:传递角色信息 } ) - resetRouter(); router.addRoutes(accessRoutes); resolve(response); } @@ -215,6 +226,7 @@ const actions = { commit("SET_AUTHS", []); removeToken(); resetRouter(); + commit("permission/RESET_ROUTES", null, { root: true }); // 清除sessionStorage sessionStorage.removeItem('user'); @@ -223,6 +235,8 @@ const actions = { sessionStorage.removeItem('orgType'); sessionStorage.removeItem('mybalance'); sessionStorage.removeItem('roles'); // 新增:清除角色信息 + sessionStorage.removeItem('juese'); + sessionStorage.removeItem('jueseNew'); // reset visited views and cached views // to fixed https://github.com/PanJiaChen/vue-element-admin/issues/2485 @@ -243,6 +257,8 @@ const actions = { commit("SET_USER", ""); commit("SET_AUTHS", []); removeToken(); + resetRouter(); + commit("permission/RESET_ROUTES", null, { root: true }); // 清除sessionStorage sessionStorage.removeItem('user'); @@ -250,6 +266,8 @@ const actions = { sessionStorage.removeItem('userType'); sessionStorage.removeItem('orgType'); sessionStorage.removeItem('roles'); // 新增:清除角色信息 + sessionStorage.removeItem('juese'); + sessionStorage.removeItem('jueseNew'); resolve(); }); }, diff --git a/f/web-kboss/src/styles/sidebar.scss b/f/web-kboss/src/styles/sidebar.scss index 8fd79e6..875de5f 100644 --- a/f/web-kboss/src/styles/sidebar.scss +++ b/f/web-kboss/src/styles/sidebar.scss @@ -25,7 +25,7 @@ // reset element-ui css .horizontal-collapse-transition { - transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out; + transition: width .28s ease, padding-left .28s ease, padding-right .28s ease; } .scrollbar-wrapper { @@ -100,11 +100,11 @@ .hideSidebar { .sidebar-container { - width: 54px !important; + width: 64px !important; } .main-container { - margin-left: 54px; + margin-left: 64px; } .submenu-title-noDropdown { diff --git a/f/web-kboss/src/views/homePage/components/topBox/index.vue b/f/web-kboss/src/views/homePage/components/topBox/index.vue index 6a4af0d..4de7b51 100644 --- a/f/web-kboss/src/views/homePage/components/topBox/index.vue +++ b/f/web-kboss/src/views/homePage/components/topBox/index.vue @@ -20,9 +20,9 @@

- 产品与服务 + 基础云

- +

@@ -116,7 +116,11 @@