# -*- coding: utf-8 -*- import json from odoo import http from odoo.http import request, Response def _json(data): """返回 UTF-8 JSON 响应,并附加本地跨域调试头。""" origin = request.httprequest.headers.get('Origin', '') resp = Response( json.dumps(data, ensure_ascii=False), content_type='application/json; charset=utf-8', ) # 允许 127.0.0.1:8888 本地前端跨域调试(生产环境走 nginx 同域,无需此头) if origin.startswith('http://127.0.0.1:') or origin.startswith('http://localhost:'): resp.headers['Access-Control-Allow-Origin'] = origin resp.headers['Access-Control-Allow-Credentials'] = 'true' resp.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' resp.headers['Access-Control-Allow-Headers'] = 'Content-Type' return resp def _post_body(): try: raw = request.httprequest.get_data(as_text=True) or '{}' return json.loads(raw) except Exception: return {} class GameApiController(http.Controller): # ============ 公开只读数据接口(前端从 Odoo 取数) ============ @http.route('/game/api/codex', type='http', auth='public', methods=['GET'], csrf=False) def codex(self, **kw): """图鉴:按分类(cat)分组,每组内按子级(sub)分组。""" model = request.env['game.codex.entry'].sudo() cat_labels = dict(model._fields['cat'].selection) recs = model.search_read([], ['id', 'cat', 'sub', 'name', 'tier', 'desc']) by_cat = {} for r in recs: by_cat.setdefault(r['cat'], []).append(r) groups = [] for cat in by_cat: subs = {} for r in by_cat[cat]: sub = r['sub'] or '其他' subs.setdefault(sub, []).append({ 'id': r['id'], 'name': r['name'], 'tier': r['tier'], 'sub': r['sub'] or '', 'desc': r['desc'] or '', 'image': '/web/image/game.codex.entry/%s/image' % r['id'], }) groups.append({ 'key': cat, 'label': cat_labels.get(cat, cat), 'subs': [{'name': s, 'items': subs[s]} for s in subs], }) return _json({'groups': groups}) @http.route('/game/api/rules', type='http', auth='public', methods=['GET'], csrf=False) def rules(self, **kw): """规则 / 等级制度 / 货币:按类别分组。""" Rule = request.env['game.rule'].sudo() Category = request.env['game.rule.category'].sudo() cats = Category.search_read( [('active', '=', True)], ['id', 'key', 'name', 'sequence'], order='sequence, id' ) cat_map = {c['id']: c for c in cats} recs = Rule.search_read( [], ['id', 'category_id', 'level', 'name', 'desc', 'rate', 'note'], order='category_id, level' ) by_cat = {} for r in recs: cid = r['category_id'][0] if r['category_id'] else None if cid not in cat_map: continue by_cat.setdefault(cid, []).append(r) groups = [] for c in cats: items = by_cat.get(c['id'], []) if not items: continue groups.append({ 'key': c['key'], 'label': c['name'], 'items': [{ 'id': it['id'], 'level': it['level'], 'name': it['name'], 'desc': it['desc'] or '', 'rate': it.get('rate') or 1, 'note': it.get('note') or '', } for it in items], }) return _json({'groups': groups}) @http.route('/game/api/currencies', type='http', auth='public', methods=['GET'], csrf=False) def currencies(self, **kw): """货币等级:兼容旧接口,实际从 game.rule 按类别过滤。""" Rule = request.env['game.rule'].sudo() Category = request.env['game.rule.category'].sudo() currency_keys = ['currency', 'material', 'token', 'honor'] cats = Category.search_read( [('active', '=', True), ('key', 'in', currency_keys)], ['id', 'key', 'name', 'sequence'], order='sequence, id' ) cat_map = {c['id']: c for c in cats} recs = Rule.search_read( [('category_id.key', 'in', currency_keys)], ['id', 'category_id', 'level', 'name', 'desc', 'note', 'rate'], order='category_id, level' ) by_type = {} for r in recs: cid = r['category_id'][0] if r['category_id'] else None if cid not in cat_map: continue by_type.setdefault(cid, []).append(r) groups = [] for c in cats: items = by_type.get(c['id'], []) if not items: continue groups.append({ 'key': c['key'], 'label': c['name'], 'items': [{ 'id': it['id'], 'level': it['level'], 'name': it['name'], 'desc': it['desc'] or '', 'note': it.get('note') or '', 'rate': it.get('rate') or 1, } for it in items], }) return _json({'groups': groups}) @http.route('/game/api/pages', type='http', auth='public', methods=['GET'], csrf=False) def pages(self, **kw): """页面板块:返回所有生效板块及其条目。""" pages = request.env['game.page'].sudo().search_read( [('active', '=', True)], ['id', 'key', 'title', 'subtitle', 'body_html'], order='sequence asc' ) out = [] for p in pages: items = request.env['game.page.item'].sudo().search_read( [('page_id', '=', p['id'])], ['id', 'icon', 'title', 'desc'], order='sequence asc' ) out.append({ 'id': p['id'], 'key': p['key'], 'title': p['title'], 'subtitle': p.get('subtitle') or '', 'body_html': p.get('body_html') or '', 'items': [{ 'id': it['id'], 'icon': it.get('icon') or '', 'title': it.get('title') or '', 'desc': it.get('desc') or '', } for it in items], }) return _json({'items': out}) @http.route('/game/api/threads', type='http', auth='public', methods=['GET'], csrf=False) def threads(self, **kw): """论坛主题(含回复),按置顶+时间排序。""" model = request.env['game.forum.thread'].sudo() cat_labels = dict(model._fields['category'].selection) recs = model.search_read( [], ['id', 'name', 'category', 'author_name', 'pinned', 'create_date', 'body_html']) out = [] for r in recs: posts = request.env['game.forum.post'].sudo().search_read( [('thread_id', '=', r['id'])], ['id', 'author_name', 'body_html', 'create_date']) out.append({ 'id': r['id'], 'name': r['name'], 'category': r['category'], 'category_label': cat_labels.get(r['category'], r['category']), 'author_name': r['author_name'] or '', 'pinned': r['pinned'], 'create_date': str(r['create_date']), 'body_html': r['body_html'] or '', 'replies': posts, }) return _json(out) @http.route('/game/api/announcements', type='http', auth='public', methods=['GET'], csrf=False) def announcements(self, **kw): """公告(仅生效的)。""" model = request.env['game.announcement'].sudo() pri_labels = dict(model._fields['priority'].selection) recs = model.search_read( [('active', '=', True)], ['id', 'name', 'date', 'priority', 'body_html']) out = [{ 'id': r['id'], 'name': r['name'], 'date': str(r['date']), 'priority': r['priority'], 'priority_label': pri_labels.get(r['priority'], r['priority']), 'body_html': r['body_html'] or '', } for r in recs] return _json(out) @http.route('/game/api/story', type='http', auth='public', methods=['GET'], csrf=False) def story(self, **kw): """小说目录:返回 卷 -> 章节 结构,供前端左侧目录(TOC)使用。""" Volume = request.env['game.story.volume'].sudo() Chapter = request.env['game.story.chapter'].sudo() vols = Volume.search_read([], ['id', 'name', 'sequence', 'intro'], order='sequence, id') chapters = Chapter.search_read([], ['id', 'volume_id', 'name', 'sequence'], order='volume_id, sequence, id') by_vol = {} for c in chapters: vid = c['volume_id'][0] if c['volume_id'] else None by_vol.setdefault(vid, []).append({ 'id': c['id'], 'name': c['name'], 'sequence': c['sequence'], }) out = [] for v in vols: out.append({ 'id': v['id'], 'name': v['name'], 'sequence': v['sequence'], 'intro': v.get('intro') or '', 'chapters': by_vol.get(v['id'], []), }) return _json({'volumes': out}) @http.route('/game/api/story/chapter/', type='http', auth='public', methods=['GET'], csrf=False) def story_chapter(self, cid, **kw): """单章正文(点击目录时按需拉取,避免一次性下发整本小说)。""" rec = request.env['game.story.chapter'].sudo().search_read( [('id', '=', cid)], ['id', 'volume_id', 'name', 'sequence', 'body_html']) if not rec: return _json({'error': '章节不存在'}) r = rec[0] return _json({ 'id': r['id'], 'volume_id': r['volume_id'][0] if r['volume_id'] else None, 'name': r['name'], 'sequence': r['sequence'], 'body_html': r['body_html'] or '', }) # ============ 登录 / 注册 / 会话(与 Odoo res.users 打通) ============ @http.route('/game/api/login', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False) def login(self, **kw): data = _post_body() db = data.get('db') or request.session.db or 'game' login = (data.get('login') or '').strip() password = data.get('password') or '' if not login or not password: return _json({'ok': False, 'error': '账号和密码必填'}) try: auth_info = request.session.authenticate(db, {'type': 'password', 'login': login, 'password': password}) uid = auth_info['uid'] except Exception: return _json({'ok': False, 'error': '账号或密码错误'}) if uid: user = request.env['res.users'].sudo().browse(uid) return _json({'ok': True, 'uid': uid, 'name': user.name, 'login': user.login}) return _json({'ok': False, 'error': '账号或密码错误'}) @http.route('/game/api/register', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False) def register(self, **kw): data = _post_body() name = (data.get('name') or '').strip() login = (data.get('login') or '').strip() email = (data.get('email') or '').strip() password = data.get('password') or '' if not login or not password: return _json({'ok': False, 'error': '账号和密码必填'}) if len(password) < 8: return _json({'ok': False, 'error': '密码至少 8 位'}) if not any(c.isalpha() for c in password) or not any(c.isdigit() for c in password): return _json({'ok': False, 'error': '密码必须同时包含字母和数字'}) User = request.env['res.users'].sudo() if User.search([('login', '=', login)]): return _json({'ok': False, 'error': '该账号已存在'}) if name and User.search([('name', '=', name)]): return _json({'ok': False, 'error': '该昵称已被使用'}) try: portal = request.env.ref('base.group_portal') user = User.create({ 'name': name or login, 'login': login, 'email': email or False, 'password': password, 'groups_id': [(6, 0, [portal.id])], }) request.env.cr.commit() # 让新用户立即对其他游标可见,便于随后自动登录 db = request.session.db or 'game' auth_info = request.session.authenticate(db, {'type': 'password', 'login': login, 'password': password}) uid = auth_info['uid'] if uid: return _json({'ok': True, 'uid': uid, 'name': user.name, 'login': user.login}) return _json({'ok': True, 'uid': user.id, 'name': user.name, 'login': user.login}) except Exception as e: return _json({'ok': False, 'error': '注册失败:%s' % str(e)}) @http.route('/game/api/me', type='http', auth='public', methods=['GET'], csrf=False) def me(self, **kw): uid = request.session.uid if uid: user = request.env['res.users'].sudo().browse(uid) if user.exists(): return _json({'uid': uid, 'name': user.name, 'login': user.login}) return _json({'uid': None}) @http.route('/game/api/logout', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False) def logout(self, **kw): request.session.logout() return _json({'ok': True})